A language for real products must never strand a project mid-development. This page is the playbook for every "Vel lacks something" or "Vel broke" moment: a decision tree from the mildest exit to the final one, each exit a supported product surface rather than folklore. The goal is that a developer — or an AI agent — finds the way out in five minutes, without abandoning the project.
The exits, in order:
- The diagnostic teaches the fix. Most walls end here, in one round —
run
velar checkand do what it says, andvelar fixwhere the rewrite is provably behavior-preserving. The rest of this page is for the walls that remain. - A missing capability or npm package →
extern module, the checked boundary and the first choice. - One JavaScript value, validated at the edge →
import js unsafe, oneType.parseinstead of a signature for every export. - Styling or markup beyond Look →
import css unsafeandunsafe:html. - A suspected compiler defect blocking you → minimal repro, issue, workaround ladder.
- The final exit → take the emitted readable JavaScript and keep shipping.
When the standard surface lacks something, or the product needs a
third-party npm package, declare a checked boundary. extern module
describes the JavaScript module's runtime exports — constants, functions,
and classes — and every use is then checked like ordinary VelarScript:
extern module "text-tools":
export const version: string
export def format(value: string) -> string
export class Formatter:
constructor(prefix: string)
def format(value: number) -> string
import js {Formatter, format, version} from "text-tools"
const formatter = Formatter(">")
print(format(version))
print(formatter.format(42))
Declared exports are presence-checked when the module loads, so a missing
export fails at the boundary with a named diagnostic instead of binding
undefined and failing far from the cause. Data the package does not
guarantee should cross as unknown and enter typed code through the runtime
validator:
type User:
id: string
name: string
extern module "user-sdk":
export def loadUser() -> unknown
import js {loadUser} from "user-sdk"
const user = User.parse(loadUser())
print(user.name)
Many packages also work without a manual declaration: safe import js
reads the package's own TypeScript declarations when they map onto Vel's
lightweight types. The exact supported shapes and adaptations are specified
in the JavaScript declaration bridge.
Each extern export, constructor, or method has exactly one declared
signature. JavaScript APIs built on overloads — or on event names that
change the listener's shape, such as on("data", ...) versus
on("close", ...) — cannot be declared per-variant today. This is the known
narrow spot of the hatch. The two honest current solutions:
- Cross with
unknownand narrow at runtime. Declare the payload asunknown, then validate per event where the shape is known:
type MessagePayload:
text: string
extern module "event-source":
export def on(event: string, listener: (payload: unknown) -> null) -> null
import js {on} from "event-source"
def handleMessage(payload: unknown):
const message = MessagePayload.parse(payload)
print(message.text)
on("message", handleMessage)
- Write one fixed adapter facade. A small JavaScript module in a package
you own can flatten the overload set into fixed single-signature functions
(
onMessage(listener),onClose(listener)), each declared precisely withextern module.
Both hatches above name a package. A global, a shim, or a handful of lines that exist only for one module has no package, and therefore no import spelling. Write the JavaScript inline instead, with its contract directly underneath:
const tokenBytes = 16
extern js(tokenBytes: number)`
export function sessionToken() {
const buffer = new Uint8Array(tokenBytes)
globalThis.crypto.getRandomValues(buffer)
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("")
}
`:
export def sessionToken() -> string
print(sessionToken())
The parenthesized list captures Vel bindings of the same name and passes them
to the emitted module as checked arguments — not text substitution. Drop the
contract and it becomes unsafe js, whose exports are unknown and carry
every caveat of section 2.
This is the tighter hatch, not the looser one. import js unsafe leaves a
whole module's exports unknown from another file, with nothing beside the
import saying what any of them is; a block puts the contract three lines under
the source it governs, and the block compiles to a sibling .js with a source
map back to these lines. If you were reaching for a
data:text/javascript, import to inline a module, that spelling is now
rejected with a mechanical rewrite into this one.
The honest limit: captures are handed to a synchronous factory, so a block
that takes them cannot use top-level await. Await on the Vel side and capture
the result.
The CSS counterpart is unsafe css, in section 3.
import js unsafe skips the signature ceremony, not the checking. Where
extern module declares a type for every export before any of them can be
used, an unsafe import declares nothing and admits the value as unknown, so
the value carries no guarantee and the compiler refuses to read a field off it,
call it, or judge it as a condition. The import statement is the boundary of
trust: parse one declared shape with Type.parse at the edge, and let only
typed values flow inward:
import js unsafe {legacyValue} from "legacy-package"
type Config:
baseUrl: string
const config = Config.parse(legacyValue)
print(config.baseUrl)
Unsafe imports are for speed, not for permanence. When the boundary
stabilizes, promote it to an extern module declaration and the compiler
starts checking every call site.
When a design needs CSS that Look does not express, native CSS is an explicit unsafe boundary with a declared position relative to Look's output:
import css unsafe "./legacy.css" before look
import css unsafe "./overrides.css" after look
before look and after look set source order only; specificity and
!important remain the stylesheet author's responsibility.
When the CSS is a few rules that belong to this module rather than a file worth keeping, write it inline. The placement is mandatory here too — there is no default order:
unsafe css`
@media print {
article { break-inside: avoid; orphans: 3; widows: 3 }
}
` after look
Paged media is the honest example: print is not a Look media subject and
break-inside, orphans, and widows are excluded properties, because paged
and fragmented media are outside the Web application target. So there is no
Look spelling being avoided — this reaches something Look does not cover at
all, which is what an escape hatch is for.
Reading a design system's CSS custom properties is not one of those things, and
this is the mistake worth naming here, because it used to be true. Every metric
Look property — width, padding, gap, borderRadius, fontSize — once refused
var(--x) and named this page as the only way out, so a whole visual layer
would follow its design tokens into a stylesheet. token("--name") is now the
checked spelling and it is legal in every Look property, so a token-driven
design stays inside Look; a raw var(--name) string is refused with token()
named, and velar fix migrates it.
unsafe css belongs to the Web extension rather than Core, for the same reason
Core has no look: Core does not know what a stylesheet is.
import css unsafe takes a relative project path — "./legacy.css" — and
nothing else. There is no import css unsafe "some-package/dist/style.css", so a
component library whose CSS lives in node_modules is brought in by copying it
into the project, which is a two-line build step and a deliberate one: the file
becomes a reviewable artifact whose version is visible in the repository instead
of a silent dependency of the visual layer.
{
"scripts": {
"sync:vendor-css": "cp node_modules/some-ui/dist/style.css src/vendor/some-ui.css",
"prebuild": "npm run sync:vendor-css"
}
}import css unsafe "./vendor/some-ui.css" before look
Three points make this workflow honest rather than a workaround. Import the copy
before look so the application's own Look wins equal-specificity conflicts.
Commit the copied file, and re-run the copy when the package updates — a
prebuild (or postinstall) script keeps that mechanical. And treat the copy as
read-only: local edits belong in a second stylesheet imported after look, or in
Look itself, so the next sync does not silently discard them. Blessing package
CSS paths directly is a possible future addition; today the copy is the supported
path.
Trusted HTML renders through the string-only unsafe:html boundary. It is a
wholesale innerHTML assignment on every reactive update, and it cannot be
combined with children. Style that markup through the unsafe CSS route
above, not through Look:
component Preview(trustedMarkup: string):
return <article unsafe:html={trustedMarkup} />
When the compiler itself appears wrong — a diagnostic that cannot be satisfied, an emit that misbehaves, a check that contradicts this documentation — do not abandon the project and do not fight the wall blindly. The path:
- Reduce to a minimal repro. Cut the program down to the smallest
.velfile that still shows the behavior. Most "compiler bugs" resolve into a teaching diagnostic during this step; the rest become perfect reports. When it still fails,velar reprobundles what is left: it writes a self-contained directory — the source the diagnostic touches,velar.json, the verbatim output, the versions, and aREADME.mdalready laid out in the three sections below — then prints the path. It uploads nothing and collects nothing about your machine; sending it is your decision. In the recorded output, paths inside the project keep their project-relative shape, and a module from outside it — a linked package, anode_modulestree above the project root — is named by its file alone, as<external>/<name>; the directories leading to it are not part of the defect. That module is still named in full, relative to the project, where theREADME.mdlists what the bundle could not carry, because that list is what tells you which package to install. - File the issue with three sections (the same structure the project's blind-test ledger uses, and the fields the repository's defect template asks for):
What I wrote (or wanted to write):
<the minimal .vel source>
What the compiler said:
<the verbatim diagnostics, or the wrong output>
How I resolved it:
<the workaround that unblocked the project, or "blocked">
- Climb the workaround ladder while waiting. In order: respell the
construct (a neighboring canonical spelling usually avoids the edge);
isolate the troublesome part into a small JavaScript module you own and
declare it with
extern module; and if the defect still blocks the project, take the final exit below. VelarScript makes no backward-compatibility promise, but defects with a repro are treated as gate failures, not as folklore.
For the Node target, velar build --mode readable --source-maps emits legible, source-mapped JavaScript —
ordinary modules with your names in them, plus a generated
node_modules/velar directory containing the standard-module runtime as
plain readable JavaScript. That generated runtime is part of the build
output, not part of the toolchain.
The default production mode bundles or minifies every target for deployment,
so that artifact is not the form you read. Pass --mode readable, or set
"build": {"mode": "readable"} in velar.json, to preserve generated names
and structure. Source maps are an independent switch: set
"build": {"sourceMaps": true} or pass --source-maps when they are needed.
If Vel itself becomes the obstacle, take the emitted output and keep
shipping without us: an emitted Node program runs in a bare directory with
nothing but Node — no @velarscript/* packages, no compiler, no CLI — and
a built web dist/ is self-contained static assets. The Node half of this
anti-lock-in property is enforced by a permanent acceptance gate
(tests/acceptance/package.acceptance.ts) that builds
a program, copies only the emitted output into an empty directory, and runs
it with Node alone.
Ejecting is one-way — you maintain the JavaScript from then on — which is exactly what makes it an honest exit: the code you leave with is code a JavaScript team can own.