Every "new UI language" I've tried treats talking to existing code as an afterthought. There's a foreign-function interface bolted on the side: you write an IDL, or a schema, or a extern block, or you marshal everything through strings and JSON at the boundary. Interop is a layer — a place where your nice new language stops and the ugly real world begins.
I built Vel the other way around. The single constraint I set on day one was: you must be able to drop any existing C++ codebase into a .vel file with one line, and call it like it was always there. Not bindings. Not a boundary. A language feature.
Here's what that actually takes in the compiler.
The lexer learned ::
A normal DSL lexer doesn't care about ::. Vel's does, because the whole interop story hinges on qualified C++ names being first-class tokens, not strings I parse later. So :: is a real token type (velc/Lexer.cpp):
tokens_.push_back({TokenType::ColonColon, "::", startLoc});
And the parser, when it's reading a name, greedily consumes :: segments to build a qualified identifier (velc/Parser.cpp):
while (check(TokenType::ColonColon)) {
auto next = advance();
name += "::" + next.text;
}
That tiny loop is what makes std::vector, myapp::db::fetchAll, and myorg::ui::Theme parse as single names rather than syntax errors. The grammar didn't need a special "FFI call" construct — qualified names just are names, everywhere a name is legal: in expressions, in type annotations, in event handlers.
use is an include, not an import system
There are exactly two use forms, and the lexer tags both with one keyword (use or Use):
use "backend/users.h" // raw C++ — emitted verbatim as #include
use "widgets/stat.vel" // cross-file vel import — pulls in components
The C++ form does the least surprising thing possible: it becomes a #include "backend/users.h" at the top of the generated translation unit. No parsing of the header, no wrapping, no shadow types. velc doesn't try to understand your C++ — it just makes sure it's in scope, and then trusts that the names you use resolve when the C++ compiler runs.
That trust is the design. velc is not a C++ frontend and never tries to be. It type-checks the Vel parts — widget props, signal types, the registry — and for anything in a used header, it emits the call and lets the downstream C++ compiler be the type checker. If you typo myapp::fetchUzers(), you don't get a velc error; you get a normal C++ "no member named" error pointing at the right place. The DSL borrows the host language's type system instead of duplicating a worse copy of it.
State can be any C++ type, including async
Because qualified names work in type annotations, your reactive state isn't limited to primitives. This is a real line of Vel:
#UserList
@users: std::vector<myapp::User> = await myapp::fetchUsers()
@filter = ""
Three things are happening that only work because interop is in the type system:
-
std::vector<myapp::User>is a type annotation with a template argument and a qualified name — the parser handles all of it because, again, names are names. -
myapp::fetchUsers()is a qualified call in an initializer expression. -
awaiton a call whose result type is a C++ type makes velc emit anAsyncSignal<std::vector<myapp::User>>, kicked off withstd::asyncand polled each tick — exposing.loading/.ready/.value/.errorto the DSL.
velc emits, roughly, a Signal/AsyncSignal member of your C++ type, initialized by your C++ call. The framework has no idea what myapp::User is. It doesn't need to. Your existing code links into the binary as an ordinary translation unit — same compiler, same flags, same linker — and the generated component holds your types directly. There's no serialization at the boundary because there is no boundary.
The same mechanism is why a registry component call compiles to a plain constructor — codegen emits vel::gen::Stat{...} the same way it emits myapp::fetchUsers(). To velc, framework calls and your calls are the same kind of thing: qualified names it resolves to C++ and hands off.
What it costs — and it's a real cost
This is the section that separates the design from a sales pitch. Making C++ a language feature means inheriting C++'s consequences with none of the guardrails a binding layer would have given you:
-
There is no safety boundary. A binding layer is also a firewall — it validates, it sandboxes, it catches type mismatches at the edge. Vel has none of that on purpose. If your
used function dereferences a null pointer, your UI segfaults, exactly like C++ does. The DSL is not memory-safe across the interop line because there is no line. You're writing C++ with a nicer syntax for the view layer, and you own C++'s footguns. -
Errors surface one layer down. A mistake in a qualified call isn't a friendly DSL diagnostic; it's a C++ compiler error in generated code. I keep the generated
.vel.cppreadable for exactly this reason, but the failure mode is still "read a template error," not "Vel told you nicely." -
No hot-swap of the native side. Vel hot-reloads
.velfiles by recompiling and swapping the generated component. But yourused C++ is compiled into the binary — changeusers.hand you're doing a real rebuild, not a sub-second reload. The interop is static, which is what makes it zero-overhead and also what makes it not live. -
It assumes one toolchain. Because the call sites are literal C++, your backend has to build with the same C++ compiler and ABI as
libvel. That's fine for a C++ shop; it's not a polyglot story.
I think it's the right trade for what Vel is for: native apps where your data layer is already C++ (or C, or anything with a C++-callable surface), and you want a dense, reactive view language on top without an integration tax. The interop being unsafe is the same reason it's frictionless — the compiler gets out of the way completely.
Every other UI language I've used would make me write a binding for fetchUsers. Vel makes me write await myapp::fetchUsers(). That difference — interop as a keyword instead of interop as a subproject — is the reason the thing exists at all.
Top comments (0)