vow
vow
Vow generates a typed client — TypeScript, or JavaScript plus a .d.ts — from your annotated Crystal methods, so the frontend can call them with the arguments and return types checked.
class API
include Vow::Exportable
@[Vow::Export]
def greet(name : String) : String
"Hello, #{name}!"
end
end
// generated client, called from TypeScript:
await api.API.greet({ name: "world" }); // => "Hello, world!", typed as Promise<string>
Vow does not include a server and does not move data over the network. It generates the client, and on the Crystal side it turns a (method name, JSON arguments) pair into a JSON result. You write the bit in the middle that carries the call from the frontend to your backend (HTTP, a CLI, a test — whatever you want).
How it works
@[Vow::Export] on its own does nothing — it's just a marker. The work happens because you include Vow::Exportable, which adds macros to your class. At compile time those macros read every method you marked and generate:
- Dispatch glue. For each method, a callback that reads the named arguments, decodes each into the type you declared (applying defaults for omitted optional args, erroring on a missing required one), calls the method, and encodes the result back to JSON. This is what
Vow::Registry#dispatchruns. - A manifest. A static description of each method — its name, argument names and types, and return type — plus any custom types they use. This can be read without creating an instance of your class, and it's what the code generator turns into the client (TypeScript or JavaScript).
The macros also check, at compile time, that every exported method has typed arguments and a declared return type. If one doesn't, the build fails with a message naming the method. Vow needs the types to generate the client and won't guess.
So there's one source — your annotated methods — feeding two paths: dispatching real calls at runtime, and generating the typed client.
Install
As a library — to call Vow from Crystal (dispatch, manifest, the codegen API) — add it to your shard.yml and run shards install:
dependencies:
vow:
github: AristoRap/vow
As the vow CLI — to run vow gen from a shell — build the binary and put it on your PATH:
git clone https://github.com/AristoRap/vow && cd vow
shards install
make build # runs the specs, then `shards build` → ./bin/vow
make copy # installs it to /usr/local/bin/vow
make deploy does a --release build then copy in one step. Throughout this README, vow means that binary on your PATH; to skip installing it, run it in place as ./bin/vow ….
Usage
1. Mark your methods
include Vow::Exportable in a class, then put @[Vow::Export] on each method you want to expose:
require "vow"
class API
include Vow::Exportable
@[Vow::Export] # exposed as "API.greet"
def greet(name : String) : String
"Hello, #{name}!"
end
@[Vow::Export(name: "math.add")] # use a custom name instead
def add(a : Int32, b : Int32) : Int32
a + b
end
end
The one rule Vow enforces is that every argument and the return type is typed (write : Nil for a method that returns nothing). Beyond that, the signature is honored as written — required args, defaults, nilable types, named-only args (after a bare *), and external argument names all carry through 1-to-1. An arg with a default value is optional: the caller may omit it.
@[Vow::Export]
def shout(name : String, excitement : Int32 = 1) : String # excitement is optional
"Hey #{name}" + "!" * excitement
end
Mark a side-effect-free read with verb: :get so an HTTP transport routes it as a cacheable GET (everything else defaults to verb: :post):
@[Vow::Export(verb: :get)]
def find(id : Int32) : User # GET — a browser/CDN can cache it
# ...
end
verb is a transport-neutral hint recorded in the manifest; a non-HTTP transport ignores it. Vow never sets cache headers — it only picks the verb. The caching policy is the app's, applied by whichever transport serves the registry.
Exporting every method
If a class is meant to be exposed wholesale, include Vow::Exportable::All instead and skip the per-method annotation — every public method is exported:
class API
include Vow::Exportable::All
def greet(name : String) : String # exposed as "API.greet", no annotation
"Hello, #{name}!"
end
@[Vow::Export(name: "math.add")] # annotation is optional — here, to rename
def add(a : Int32, b : Int32) : Int32
a + b
end
@[Vow::Export(skip: true)] # keep a public method off the wire
def diagnostics : String
"..."
end
private def helper : Int32 # private/protected are never exported
42
end
end
The same typing rule applies — but now to every public method, so opting a class into All means committing to typed arguments and an explicit return type across the board. Methods that aren't a plain identifier — operators (+, []), setters (name=), and predicate/bang methods (valid?, save!) — are skipped automatically; add an explicit @[Vow::Export(name: "...")] to force one onto the wire under a clean name. Either flavor mounts the same way (below).
2. Call a method from Crystal
Build a registry from one or more service instances and dispatch by name:
registry = Vow::Registry.new(API.new)
registry.dispatch("API.greet", %({"name": "world"})) # => %("Hello, world!")
registry.dispatch("math.add", %({"a": 2, "b": 3})) # => "5"
registry.dispatch("API.shout", %({"name": "sam"})) # => %("Hey sam!"), excitement defaults
Arguments go in as a JSON object keyed by argument name; an optional arg can be left out. The result comes back as a JSON string. This is the seam your transport sits on.
3. Generate the client
--out is a stem; the target appends the extension(s):
vow gen --entry api.cr --target ts --out client # → client.ts (one module)
vow gen --entry api.cr --target js --out client # → client.js + client.d.ts
Vow compiles your file and runs it just far enough to read the exported methods (before any of your own startup code runs), then writes the client. You don't write a separate script to extract anything — pointing --entry at your source is enough.
Use ts for a project with a bundler/TypeScript toolchain; use js for a buildless browser (it runs the .js directly and reads the .d.ts for types).
4. Use the client
For the common case — HTTP to a mounted endpoint — the generated file ships a batteries-included transport, so wiring up is one line:
import { createHttpClient } from "./client";
const api = createHttpClient("/rpc"); // done
await api.API.greet({ name: "world" }); // typed Promise<string>
createHttpClient(url, options?) builds each procedure's URL under url (a read is a GET with args in ?input=, a write is a POST with a JSON body), returns the decoded result, and throws a typed VowError on the error envelope. Need to send auth or other headers? Pass a headers function — it's evaluated per request, so a token that changes (or a reactive ref) is read fresh each call:
const api = createHttpClient("/rpc", {
headers: () => ({ "X-User": user.value }),
});
The escape hatch. When you need a transport Vow doesn't ship — batching, retries, websockets, auth refresh — the generated file also exports the lower-level createClient(transport). You supply the transport: the part that sends (name, args, verb) to your backend and returns the result. verb is the procedure's HTTP verb ("get" for a read, "post" otherwise); the default createHttpClient is just this with a fetch wrapped in:
import { createClient, VowError } from "./client";
const api = createClient(async (name, args, verb) => {
const path = `/rpc/${name.replaceAll(".", "/")}`; // Users.find -> /rpc/Users/find
const res = await fetch(
verb === "get"
? `${path}?input=${encodeURIComponent(JSON.stringify(args))}`
: path,
verb === "get"
? { method: "GET" }
: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(args),
},
);
const data = await res.json();
if (!res.ok) throw new VowError(data.error, data.message, data.hint ?? null);
return data;
});
await api.API.greet({ name: "world" }); // typed Promise<string>
Ready-made server-side transports live in their own shards: a registry is the seam they mount, and the client is regenerated from its manifest.
Errors
Vow raises a typed error across the dispatch boundary — a stable string code, a message, and an optional hint — which a transport forwards as the wire envelope { error, message, hint }. The generated client mirrors this back as a VowError class and a VowErrorCode union, so a catch is typed and the built-in codes autocomplete:
import { VowError } from "./client";
try {
await api.Todos.toggle({ id });
} catch (e) {
if (e instanceof VowError) {
e.code; // VowErrorCode: "not_found" | "bad_input" | "unauthorized" | "internal" | (string & {})
e.hint; // string | null
}
}
The four built-in codes come straight from Vow's Vow::Error constructors; the union's open (string & {}) arm keeps a downstream code your service or transport mints (rate_limited, …) type-checking too. The default createHttpClient transport throws VowError for you; a custom createClient transport throws it itself (see the escape-hatch example above).
Custom types
If an exported method uses your own struct or class, include JSON::Serializable in it. Vow finds it automatically (including through arrays, unions, and nested types) and generates a matching TypeScript interface:
struct User
include JSON::Serializable
getter id : Int32
getter name : String
def initialize(@id, @name); end
end
class API
include Vow::Exportable
@[Vow::Export]
def find(id : Int32) : User?
# ...
end
end
Field names follow what actually crosses the wire: a field renamed with @[JSON::Field(key: "displayName")] appears in the interface as displayName, and a field marked @[JSON::Field(ignore: true)] is left out entirely. The interface never disagrees with the JSON.
If a referenced type can't be serialized, Vow stops with an error rather than generating a client that wouldn't work.
Naming
The generated client is idiomatic TypeScript: method names and argument keys are camelCased (def find_user(user_id : Int32) becomes api.API.findUser({ userId })). Namespace segments are your Crystal class and module names, so they're left as written (API, not api). Custom type and struct-field names follow the JSON exactly (see Custom types).
CLI
vow gen Generate a typed client from your methods
-e, --entry <app.cr> your Crystal source file
-m, --manifest <file.json> a pre-generated manifest file, instead of --entry
-o, --out <stem> output path stem; the target's extension is appended
-t, --target <ts|js> ts → one .ts module; js → .js runtime + .d.ts types
-c, --check verify --out is up to date; write nothing, exit ≠0 if it would change
vow version Print the Vow version
A single-file target (ts) writes to stdout when --out is omitted (so you can pipe it); status messages go to stderr. The js target emits two files, so it needs --out.
If you commit the generated client, add --check to CI to guarantee it never drifts from your services — it regenerates in memory and compares against the files at --out, exiting non-zero (and naming what's stale) without writing:
vow gen --entry api.cr --target ts --out client --check
Scope
A few things are deliberate, not missing:
- No transport lock-in. The generated client ships a default HTTP transport (
createHttpClient) for the common case, but it's built on the framework-agnosticcreateClient(transport)seam — so anything else (batching, retries, websockets) is yours to supply, and Vow never binds you to a web server. Ready-made server-side bindings live downstream, in their own shards. - TypeScript / JavaScript client. The frontend is written in TypeScript/JavaScript, so those are the targets: a
.tsmodule, or a.jsruntime plus a.d.ts.
A few signature shapes aren't supported yet. Rather than silently miscompile, exporting one is a compile-time error naming the method, so you find out at build time:
- Splat args (
*nums : Int32) and double splats (**opts : Int32) — not yet representable over the named-args boundary. (A bare*, which only marks the following args as named-only, is supported.) - Block parameters (
&blk) — a block can't cross a JSON boundary. - Default values that reference an earlier parameter (
def f(a, b = a)) — the default is materialized in generated code where the earlier parameter isn't in scope; use a literal default instead.
Development
crystal spec # run the tests
Vow uses argy for its CLI. If shards install fails with a safe.bareRepository error, your global git has safe.bareRepository = explicit; run the command with this prefix:
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.bareRepository GIT_CONFIG_VALUE_0=all shards install
License
MIT
vow
- 0
- 0
- 0
- 1
- 1
- about 2 hours ago
- May 31, 2026
MIT License
Sun, 31 May 2026 13:47:24 GMT