This is the reading path: the whole language in the order it makes sense to learn it, with a runnable program at every step. It is not the contract. The contract is the language charter — three thousand lines that answer exactly what happens in every corner, and that the compiler is tested against. This page gets you to the point where you can read the charter when you need it, and not before.
Read it top to bottom once. Each section ends with ↳ charter §N, which is where to go when you need the exact rule rather than the working idea.
If you have not built anything yet, start with Getting started — ten minutes to a running application. When you want the house style rather than the mechanics, that is Best practices.
Two parents. Vel's behaviour comes from JavaScript and its spelling comes from Python. When you are unsure how something behaves, guess JavaScript; when you are unsure how something is written, guess Python. Where the two disagree about which of five spellings to keep, Vel keeps one — and the compiler names it when you write another.
A VelarScript file is a .vel file. A block opens with : and is carried by
indentation, exactly as in Python — four spaces by convention, and velar format settles it either way. There are no semicolons and no braces around
statement blocks. One statement per line; as in Python, an ordinary executable
suite may instead put its single non-block statement after the colon. Multiple
statements, nested blocks, and structural member or branch lists keep the
indented form. A line that begins with . or ?. continues the previous line,
which is what lets method chains break naturally.
Comments are // for a line, /// for documentation attached to the following
declaration, and /* */ for a region — the block form nests, and a multi-line
one takes whole lines.
A statement must do something. A line that only computes a value and drops it is a compile error, so a bare string is not a docstring.
/// Returns the greeting shown on the dashboard.
def greeting(name: string) -> string:
// One statement per line; no semicolons.
return f"Hello, {name}"
print(greeting("Ada"))
↳ charter §2 Files, comments, and blocks
const binds a name that cannot be reassigned; let binds one that can.
There is no var. Integers may use explicit 0x, 0b, or 0o radix prefixes;
legacy leading-zero octal and bare .5 remain invalid, and long digits group
with _. true/false/null are
the three keyword literals; there is no undefined.
Strings come in three delimiters and two prefixes that combine. Double quotes
are the default; backticks hold text that itself contains " (a JSON fixture, a
CSS selector) without escaping; a quote followed immediately by a newline opens
a layout string, closed by a quote back at the opening line's indentation.
The prefixes are f (interpolation) and r (raw), and rf is both. Only an
f prefix interpolates — ${value} is ordinary text in every string.
const limit = 1_000_000
let attempts = 0
attempts += 1
const name = "Ada"
const greeting = f"Hello, {name}"
const fixture = `{"name":"Ada"}`
const pattern = r"\d+"
const usage = "
velar check
velar test
"
print(f"{limit} {attempts} {greeting} {fixture} {pattern}")
print(usage)
↳ charter §3 Bindings and literals
Arithmetic and comparison read as you expect. The words are and, or, not
— not &&, ||, !value. Equality is ==/!= and it never coerces: the two
sides must have overlapping possible values, so 1 == "1" does not compile.
Equality also never chains — write a == b and b == c. Ordered comparisons do
chain, but must point one way: 0 < index <= size.
A condition judges truth, not presence. if, while, and, or, not,
and ? : accept bool and bool? only. There is no truthiness, so an
optional is tested explicitly with != null. The conditional expression is
cond ? a : b; there is no x if cond else y.
?? supplies a default for an optional and ?. reaches through one, but ??
never shares an unparenthesized chain with and/or. value! takes the third
road: it unwraps an optional and raises where the value turns out to be absent,
for the places where absence would mean the program is wrong. It checks — there
is no spelling that merely claims. ! after a value is that unwrap; ! before
one is the negation above, and != still wins by longest match, so an unwrap
before an equality test needs its space: value! == other.
Integer literals may use 0x, 0b, or 0o. Bitwise ~, &, |, ^,
<<, >>, and >>> (and their compound assignments) accept only checked
32-bit integers; shift counts outside 0..31 fail instead of wrapping.
type Account:
nickname: string?
def title(account: Account, index: number) -> string:
const named = account.nickname != null and index > 0
const label = account.nickname ?? "anonymous"
return named ? f"{index}. {label}" : label
print(title({nickname: "ada"}, 1))
print(title({nickname: null}, 0))
↳ charter §4 Operators
The core types are string, number, bool, null, and unknown. T? is
an optional. Types are not erased: a Vel type carries a runtime validator,
which is why there is no import type and why Type.parse(value) exists at
every boundary.
unknown is the honest type for data that has not been validated yet — you
cannot use it until you narrow it. any is not a type you may write, and no
boundary hands one back either: an unsafe JavaScript import arrives as
unknown, exactly like every other unvalidated value. Foreign data therefore
obeys one rule, and it is the checked one — narrow it with value is T, or
parse a declared shape.
Numbers and strings carry their operations as methods, not as free
functions: value.round(), value.sign(), value.trunc(), value.isNaN(), text.isBlank(),
text.split("\n").
type Reading:
label: string
celsius: number
def describe(untrusted: unknown) -> string:
const reading = Reading.parse(untrusted)
const rounded = reading.celsius.round()
const blank = reading.label.isBlank()
return f"{reading.label}: {rounded} (blank {blank})"
print(describe({label: "kitchen", celsius: 21.4}))
↳ charter §5 Core types
One keyword, type, declares both record shapes and aliases. A record type is
structural — any value with the right fields satisfies it — and it carries
parse and is for untrusted data. Fields may be optional (name: string?)
and individually readonly. A record may extend one concrete record; the child
inherits its fields and validator checks while assignability stays structural.
Use readonly type Name: when the entire record contract, including inherited
fields and nested data, is read-only.
enum declares a finite state backed by a wire value. A member may carry an
external spelling — a string (textDelta = "response.output_text.delta") or a
safe integer where a protocol pins a version (v2 = 2) — without losing its
nominal identity, and Kind.values() returns the members in declaration order.
An enum member in type position discriminates a union.
enum Status:
pending
active
done
type UserId = string
type Entity:
id: UserId
type User extends Entity:
name: string
avatar: string?
status: Status
def line(user: User) -> string: return f"{user.id} {user.name} {user.status}"
const user: User = {id: "u-1", name: "Ada", avatar: null, status: Status.active}
print(line(user))
print(f"{Status.values().size} states")
print(f"{Status.parse("done")}")
↳ charter §6 Records, aliases, and enums
Functions are def, with typed parameters, optional defaults, and a result
annotated after ->. A call may name its arguments with name=value — not
name: value. Lambdas use => and are single expressions.
There are two arrows. => is the value-level arrow: it introduces a lambda
body. -> is the type-level arrow: it names a result. They meet on a callback,
where the type is written with -> and the value passed to it with =>.
A generic function names a bound only when its body needs the capability:
<T: Comparable> to order, <T: Text> to interpolate, <T: Data> to
serialize. Type arguments are never written at a call: the arguments solve
them, and whatever they leave open the position solves — the same annotation
that would settle an empty [], so const names: List<string> = empty()
calls def empty<T>() -> List<T> with T as string.
A def is visible throughout its lexical block, so a helper may be written
after its first call and sibling helpers may recurse into each other.
type Transform = (value: number) -> number
def apply(values: List<number>, using: Transform) -> List<number>: return values.map(using)
def scale(value: number, factor: number = 2) -> number: return value * factor
def join<T: Text>(values: List<T>) -> string: return values.map(str).join(", ")
const doubled = apply([1, 2, 3], value => scale(value))
print(join(doubled))
print(str(scale(10, factor=3)))
↳ charter §7 Functions and calls
List<T>, Set<T>, Map<K, V>, and Record<T> are the four, and their
methods are compiler-checked rather than inherited from JavaScript. Size is
.size everywhere — on strings too. Appending is .append(x); there is no
push, splice, shift, or mutating sort. Sorting and reversing copy:
.sorted(by=...), .reversed().
Prefer the collection API when it states the whole operation: filter, map,
and flatMap for stateless selection and projection; some, every, and
find for queries; sum, min, max, and reduce for intentional folds;
groupBy, keyBy, countBy, partition, chunk, zip, unique,
compact, and flatten when the answer is a different collection.
Every callback that receives an element — predicate, transform, key selector,
and reduce's combine alike, sorted(by=) included — receives the value and,
when declared, its stable snapshot index in the parameter after it:
tasks.map((task, index) => f"{index}: {task.title}") and
tasks.reduce((total, task, index) => total + index, 0).
Use for when the work mutates state, has custom exits, writes multiple
outputs, or depends on ordered effects. velar check reports A8/A13 only for
the narrow loop shapes it can prove equivalent.
[index] throws when the index is out of range, and .get(index) returns
T?. Choose by whether absence is a bug or an expected answer — that choice is
the signal the next reader gets. Membership is in.
type Task:
title: string
priority: number
const tasks: List<Task> = [
{title: "Ship the parser", priority: 2},
{title: "Write the tour", priority: 1},
]
tasks.append({title: "Fix the gate", priority: 3})
const ordered = tasks.sorted(by=task => task.priority).map(task => task.title)
const urgent = tasks.sorted(by=task => task.priority, descending=true)
const byPriority = tasks.groupBy(task => task.priority)
const total = tasks.map(task => task.priority).sum()
const first = tasks[0].title
const missing = tasks.get(99)?.title ?? "none"
const owners = Map([["t-1", "ada"], ["t-2", "lin"]])
const ownerCursor = owners.iterator()
const firstOwner = ownerCursor.next()
const eventsByDay: Map<string, List<Task>> = Map()
eventsByDay.getOrSet("today", []).append(tasks[0])
eventsByDay.getOrSetWith("tomorrow", () => []).append(tasks[1])
const words = Set(["done", "closed"])
print(ordered.join(" | "))
if firstOwner != null: print(firstOwner.value)
print(f"{total} {first} {missing} {owners.get("t-1")} {"done" in words}")
print(f"{urgent[0].title} {byPriority.size}")
↳ charter §8 Collections
if / else if / else, while, and for … in …. The for loop takes a
second slot for the position — for value, index in values: — so a shadow
counter is never needed. range(...) is in the prelude and needs no import. A
direct one-slot for value in range(...): is compiled as a checked native
counter loop; using range(...) as a value still returns the ordinary bounded
List<number>.
An ordinary executable suite with one non-block statement may stay on one line,
as in def stop(): return or while pending: poll(). An if branch follows
the same rule. The formatter preserves the author's choice between that spelling
and an indented suite; it does not collapse or expand the block. Multiple
statements and nested blocks keep the indented form. Conditional values are
still written condition ? then : else.
match is the dispatcher: one subject, case branches that may destructure
records and lists, an optional if guard, and case _: as the only fallback.
An else if ladder over an enum is the shape match exists to replace.
Like an if branch, a case with one non-block statement may stay on its header
line: case Status.done: return "done".
enum Phase:
todo
doing
done
type Charge:
kind: string
amount: number
def advance(phase: Phase) -> Phase:
match phase:
case Phase.todo: return Phase.doing
case Phase.doing: return Phase.done
case _: return Phase.todo
def describe(event: Charge) -> string:
match event:
case {kind: "charge", amount} if amount > 100: return f"large charge of {amount}"
case {kind: "charge", amount}: return f"charge of {amount}"
case _: return "other"
let phase = Phase.todo
for step in range(3):
phase = advance(phase)
print(f"{step}: {phase}")
print(describe({kind: "charge", amount: 250}))
↳ charter §9 Control flow
Classes have typed body fields, one explicit constructor, and an explicit
self. Instances are called directly — there is no new. A const/let
prefix on a constructor parameter declares the field at the same time.
VelarScript calls @name a context marker; @ is the marker
introducer. A context marker attaches a compiler-owned compile-time role to
the following declaration or structural entry, chosen by the current syntax
context. It is not a function call or runtime decorator, and source code cannot
define one. The class context currently accepts @dispose: and @iterate:.
That closed vocabulary can never collide with yours. @dispose: is the
release contract: using name = expression runs it on every exit from the
owning scope — block end, return, break, continue, a throw three frames
down — in reverse declaration order. An owned value may not leave its scope,
so return the data you read from it rather than the handle.
The same rule covers every context marker; the active context decides the closed vocabulary and the editor hover explains the exact role and a legal example:
| Context | Compiler-owned names | Meaning |
|---|---|---|
| Module | @main: |
The program entry selected by run, an application host, or another executable target |
| Class | @dispose:, @iterate: |
Release and iteration contracts |
Node server |
@get, @post, @put, @patch, @delete, @notFound |
Anonymous checked HTTP routes and the final fallback |
| Web component | @mounted:, @cleanup: |
Component insertion and destruction lifecycle |
Web look: |
@hover and the other state names; @before: and the other target names |
Live CSS states and checked pseudo-element targets |
These names cannot be imported, aliased, passed as values, called, or extended by user code. A valid hover comes from the compiler's parsed semantic position, so text that merely happens to contain the same spelling is not treated as the language feature.
class Session:
let open: bool = true
constructor(const name: string): pass
def close(): self.open = false
@dispose: self.close()
def work(name: string) -> string:
using session = Session(name)
return f"{session.name} open: {session.open}"
print(work("import"))
A class takes type parameters the way a def and a type do — class Stack<T: Comparable>: — and applies them at every use: Stack<number> is the
type, a bare Stack is not one, and Stack() reads its arguments from the
constructor call and from the position, never from a written <...>. A class
is invariant in them (a Stack<number> is not a Stack<number | string>), the
arguments are erased at runtime (is Stack checks the class, is Stack<number>
is refused), and a static member does not see them.
↳ charter §10 Classes
assert condition else "message" states a contract at the top of a function;
the message is required and reads as a sentence. Failures that the caller does
not expect are try / catch / finally blocks. Failures the caller
does expect are the try expression, which produces null when anything
in the chain throws — its result must be consumed, usually by ??.
An error's class is its classification: discriminate with
if error is FileNotFoundError:. error.code is that same identity in string
form, for when it must survive a log or a JSON boundary.
type Settings:
retries: number
def defaultSettings() -> Settings: return {retries: 3}
def load(raw: unknown) -> Settings:
const settings = try Settings.parse(raw) ?? defaultSettings()
assert settings.retries >= 0 else "A retry count is never negative"
return settings
def read(values: List<number>, index: number) -> string:
try: return str(values[index])
catch error:
if error is IndexError: return "out of range"
throw error
print(str(load({retries: 5}).retries))
print(str(load("not a settings record").retries))
print(read([1, 2, 3], 99))
↳ charter §11 Errors and assertions
Export and import by name. A package has a mandatory root source entry and may
add exact subpath entries; each entry exposes named exports directly or through
an explicit re-export barrel. The package name selects velar.entry, while
toolkit/worker selects velar.entries["./worker"]; undeclared subpaths do not
fall back to a directory or index.vel. Declarations initialize when a module
is imported; application startup belongs to the module's @main region and
runs only when that module is selected as a program entry. The manifest rules
are in package distribution.
What a program can compute needs no import; what reaches outside the
program must be imported. Four namespaces are permanent because they mirror a
JavaScript global — Json., Promise., Math., and Text. — and everything
else, including velar/http, velar/storage, and velar/look, is an ordinary
named import.
Third-party JavaScript enters through a checked boundary: extern module
declares the shape you are relying on, and import js then reads as ordinary
Vel. Declare the extern block once, in an adapter module, and re-export a
checked surface from it.
type Payload:
id: string
extern module "some-sdk":
export def load() -> unknown
import js {load} from "some-sdk"
export def payload() -> Payload: return Payload.parse(load())
@main: print(f"{Math.max(1, 2)} {Json.stringify({id: "p-1"})}")
↳ charter §12 Modules and JavaScript boundaries · Escape hatches · JavaScript bridge
A test lives in a *.test.vel module as a test "name": block. The name is a
sentence about the behaviour, quoted verbatim by the reporter. The body may
await directly and needs no export; expect comes from velar/test. A
*.browser.test.vel module drives a real page instead, through velar/web-test.
import {expect} from "velar/test"
def initials(name: string) -> string: return name.split(" ").map(part => part.slice(0, 1)).join("")
test "a full name becomes its initials": expect(initials("Ada Lovelace")).toBe("AL")
test "a single name keeps one initial": expect(initials("Ada")).toBe("A")
↳ charter §12 Modules and JavaScript boundaries · CLI reference
An application service activates @velarscript/server in velar.json; that
application extension composes @velarscript/node. server declares
an immutable route table; the five HTTP verbs are context markers with
compiler-owned route roles, not decorators. A Node-owned p"..." value is a
first-class RoutePattern: it declares path and query fields once. An inline
pattern projects those fields directly into the anonymous handler; a referenced
pattern uses an explicit as route binding. One Data
record on a writing route comes from JSON. Distinct URL and field names use
wire={field:type}; redundant same-name mappings receive advisory A11 and a
mechanical shorthand fix.
import {HttpProblem, Request, created} from "velar/serve"
type CreateArticle:
title: string
export server app:
@get(p"/health") => {ok: true}
@get(p"/articles/{id:number}?{details:bool?}"):
if id < 1:
throw HttpProblem({status: 404, reason: "article.not_found", title: "Article not found"})
return {id, details: details ?? false}
@post(p"/articles", input: CreateArticle) => created({id: 1, title: input.title})
@notFound(request: Request) => {error: "route_not_found", path: request.path}
p"..." belongs to the Node extension rather than Core's string system.
f"..." therefore keeps its one job—forward runtime interpolation—while a
path pattern is a reverse matcher checked entirely at compile time.
The selected entry starts the server inside @main; importing that module does
not run the entry body. server.configuration in velar.json explicitly names
the project-relative YAML or JSON file that owns host, port, request limits,
and application settings.
velar dev watches and restarts it, velar serve runs it with production
behavior, and velar build emits the standalone Node application. Direct
serve(...) remains an ordinary Node operation for integration tests and
embedded servers.
authenticate(security.bearer(), verify) composes Node's checked credential
input with a nullable async verifier and exposes its non-null identity as a
request Provider. The framework owns the 401 boundary; installed libraries own
token/session algorithms, and the application owns user and authorization
policy.
@notFound is the single application fallback for a path that matches no
route. Its optional parameter must be Request; Data keeps the 404 status,
while an explicit response may select another status. It does not intercept a
matched route's HttpProblem or a method-not-allowed response. Semantic route
results are finalized once by the framework; an optional application-wide
@response(outcome: HttpOutcome, request: Request) policy may define a shared
envelope, while explicit json, text, file, stream, sse, and
redirect results remain final representations.
↳ charter §3 Bindings and literals
· Standard library: velar/serve
The remaining sections belong to the Web extension, which a project turns
on by naming @velarscript/web in velar.json. Without that line, component
is an unknown declaration keyword and every JSX token is a parse error. A Core
project—or a Node service after the section above—stops reading here.
component declares a component; props are its parameters; it returns JSX
directly, with no render block. JSX is expression syntax, so conditional
rendering is cond ? a : b and lists are items.map(...) with a key —
there are no control-flow attributes.
Event handlers are on:click={handler}, form bindings are
bind:value={state}, and children arrive through a declared children: WebNode
prop. Props are live reactive inputs, and their data is mutable by default:
writing a field or using a collection's mutating method publishes through the
same deep-reactive path as writing the source state. Declare readonly T on a
prop when the component author deliberately wants a read-only contract; that
explicit view protects the qualified layer. Nested values keep their declared
types; use explicit qualifiers on their layers to protect their slots too.
type Item:
id: string
title: string
component Empty:
return <p>Nothing yet</p>
component ItemList(items: readonly List<Item>, children: WebNode):
return <section>
{items.size == 0 ? <Empty /> : <ul>
{items.map(item => <li key={item.id}>{item.title}</li>)}
</ul>}
{children}
</section>
mount(<ItemList items={[{id: "i-1", title: "Read the charter"}]}>
<footer>1 item</footer>
</ItemList>, "#app")
↳ charter §13 Web extension boundary · §14 Components and JSX · Web framework reference
Four cells, one job each. state holds a fact. computed name = ... derives
from facts and is read bare, like state. resource loads async data — it
exposes value, loading, error, and reload(). action performs a user
operation and exposes pending and error.
The two synchronous ones complete a grid the whole language already had:
| not reactive | reactive | |
|---|---|---|
| writable | let |
state |
| read-only | const |
computed |
state is to let what computed is to const. That is what makes const
worth reading: it now promises the value never changes. There is no third
spelling: a derived value is declared, and the declaration already caches.
Reactivity is deep, so you mutate state directly rather than rebuilding it.
Two things surprise newcomers, both deliberate: a resource loads once, at
mount, and does not refetch when its inputs change — watch the input and
call reload(); and an action does not queue, so two clicks run two calls
and you guard with disabled={save.pending}.
component TicketPanel(id: string):
state draft = ""
resource ticket: Ticket = loadTicket(id)
computed heading = ticket.value?.title ?? "Loading"
action save():
await saveDraft(id, draft)
watch id:
detach ticket.reload()
return <section>
<h2>{heading}</h2>
<textarea bind:value={draft}></textarea>
<button type="button" disabled={save.pending} on:click={save}>Save</button>
</section>
(Ticket, loadTicket, and saveDraft are declared for the compiler in a
Markdown comment above this fence, so the example shows only the four cells and
the gate still checks it in full.)
↳ charter §15 State, computed values, resources, and actions
The component context has two context markers. @mounted: runs once after
the DOM is inserted and may await. @cleanup: runs once before the component
is destroyed and is synchronous. @ is the same marker introducer used in
classes and Look; it does not mean "lifecycle". Because these markers are
compiler-owned, a component can still declare its own ordinary
def mounted().
component Chart(points: readonly List<number>):
let canvas: CanvasElement? = null
@mounted:
if canvas != null: print(f"drawing {points.size} points")
@cleanup:
print("releasing")
return <canvas ref={canvas}></canvas>
mount(<Chart points={[1, 2, 3]} />, "#app")
↳ charter §16 Lifecycle
Look is the checked visual language, and it is a value: look: builds one,
look={...} applies it to an element. Property names are real DOM camelCase,
CSS keywords are quoted strings, and units are literal (16px, 1turn, 1s).
Builders such as spacing, border, rgb, and animate are named imports
from velar/look.
A look: literal is built once, so its conditions cannot read state — put a
reactive visual on the element (look={active ? a : b}) instead. Conditions
inside a literal cover the static axes: @hover, viewport.width <= 640px,
motion.reduced.
import {animate, border, rgb, spacing} from "velar/look"
const spin = keyframes:
from:
rotate = 0deg
to:
rotate = 1turn
const cardLook = look:
display = "grid"
gap = 12px
border = border(1px, rgb(220, 224, 235))
borderRadius = 10px
padding = spacing(16px, 20px)
if @hover:
background = rgb(245, 247, 255)
if viewport.width <= 640px:
padding = spacing(12px, 14px)
const busyLook = look:
if not motion.reduced:
animation = animate(spin, 1s, easing="linear", loop=true)
component Card(busy: bool, children: WebNode):
return <article look={cardLook}>
<span look={busy ? busyLook : null}>*</span>
{children}
</article>
mount(<Card busy={true}><p>Loading</p></Card>, "#app")
A design system's CSS custom properties are read with token("--name"), the one
checked spelling, legal in every Look property. The compiler checks the
reference — a literal --name — and the design system owns the value behind it,
so a theme swaps values under the same names without recompiling:
import {token} from "velar/look"
const shellChrome = look:
width = token("--shell-sidebar-expanded-width")
borderRadius = token("--ui-radius-panel")
boxShadow = token("--shell-sidebar-shadow")
color = token("--ui-color-foreground")
↳ charter §17 Look · appendix: the published visual vocabulary
Vel is small on purpose, and the charter lists the absences with the reason for
each: no switch, no var or undefined, no new or this, no typeof /
instanceof / eval, no yield, no user-defined decorators, no
TypeScript-style type programming, no React-style effect. If you reach for
one of these, the compiler names what to write instead — that is the design,
not a gap.
↳ charter §19 Deliberately absent source features
Readable, source-mapped JavaScript that runs without the toolchain, with stable
CSS selectors and no framework runtime beyond the explicit @velarscript/web
package. This is a product promise, not an implementation detail: if Vel itself
ever becomes the obstacle, you take the emitted output and keep shipping.
↳ charter §18 Generated JavaScript semantics · Escape hatches
- Best practices — the house style, one complete program per rule
- Standard library — the permanent namespaces and the capability modules
- Web framework — the full component, routing, and Look surface
- CLI reference — every command, grouped by what you are doing
- The language charter — the contract, when you need the exact rule
examples/tour/— every spelling exactly once, as projects you can run