Experimental compile-time checked Handlebars templates for Rust. Based on the parser from rusty-handlebars.
Code first or schema first highlights that there are two way of thinking about templating. Code first or template first.
This library takes a template first approach. The designer makes pure handlebars files (hbs) that can be edited
separately. Then the Rust developer gets a pure rust experience with compile time checking of templates and how they are
called from Rust.
The Rust developer should not have to repeat the template name or variable names in Rust code. They should be able to use autocomplete in their IDE.
cargo add typed-handlebarsNow make a directory of Handlebars files. eg, templates/button.hbs:
Then in rust:
mod templates {
typed_handlebars::directory!("templates/");
}
fn get_html() -> String {
// templates::button module and struct is automatically generated
templates::button::Vars { btn_id: 42, btn_name: "Save" }.render()
}Vars is every variable the template uses, in an ordinary struct.
Your IDE can offer the names rather than you retyping them, and every mistake is caught where you made it:
error[E0560]: struct `button::Vars` has no field named `btn_nmae`
help: a field with a similar name exists: `btn_name`
error[E0063]: missing field `btn_name` in initializer of `button::Vars`
That last one is the point of naming them all: add {{ subtitle }} to the .hbs and every call
site stops compiling until it says what the subtitle is, rather than quietly rendering nothing.
Handlebars renders an undefined variable as nothing, and builder() is how you say that. Set what
you have; anything you leave out renders as empty, a list with no items, or a false condition:
templates::button::builder().btn_id(42).render() // btn_name renders as nothingSo use: Vars when you have everything or builder() when you don't.
As much as possible at compile time, as little as possible at runtime. Templates are turned into Rust when the crate is built, so there is no parsing, no template registry and no lookups while your program runs — just the code the template implies. Where a design choice trades build-time work against run-time work, build time wins.
The Handlebars author needs to know no Rust. An .hbs file is plain Handlebars, written by someone who never has to
think about what happens downstream. No Rust type names, no annotations, no macro-specific syntax — nothing in the
template that a designer could not write, or that would stop the same file rendering under handlebars.js given the
registerHelper calls it names.
The code generator takes on the complexity. The template already says what data it needs: {{#each rows}}{{ name }}
means a list of records with a name. The macro reads that and generates the types, so nobody has to declare them
twice. There are no traits for you to implement and nothing to derive.
The Rust developer only does the wiring. Connect your data to what the generator produced, with the names supplied by IDE autocomplete rather than retyped from the template. Getting a name wrong should be a compile error, not something you discover in a rendered page.
Still alpha. The table below is the whole of it — anything not listed is a compile error naming the construct, never a silent difference and never a Rust type error you would have to decode.
| Construct | Notes |
|---|---|
{{ name }} |
HTML-escaped |
{{{ name }}} |
raw, for markup you have already rendered |
{{ person.name }} |
a person record is generated |
{{ rows.length }} |
how many items; countable and iterable at the same time |
{{ ../name }} |
reaches the enclosing scope |
{{@root.name}} |
reaches the top level from any depth; {{@root}} alone is an error |
{{#if}} / {{#unless}} / {{else}} |
Handlebars truthiness; testing a variable does not stop you printing it |
{{else if}} / {{else unless}} |
chained onto {{#if}} / {{#unless}}, to any depth |
{{#each rows}} |
with {{this}}, {{@index}} {{@first}} {{@last}}, {{else}}, as |row| |
{{#with person}} |
see the divergence below |
{{ t "Save" }} |
a helper (t): a method on the frame — see usage |
{{> row}} |
partials, rendered against the context they were included from |
{{! … }} / {{!-- … --}} |
comments, including the trimming closes {{! … ~}} and {{!-- … --~}} |
{{~ … ~}} |
whitespace trimming |
| a tag alone on a line | indentation and newline go; a partial's indent reaches its every line |
\{{ … }} and {{{{raw}}}} … {{{{/raw}}}} |
literal output |
{{@key}} {{@value}} · {{lookup}} ·
sub-expressions ( … ) · {{#with}} with {{else}} · partial arguments ({{> row this}}) ·
inline partials ({{#*inline}}) · […] path segments, both indexing ({{ rows.[0] }}) and
quoted names ({{ [odd name] }}) · lists that are not slice-backed (HashMap, VecDeque) ·
hash arguments ({{ t "Hello" name=user }}) · block helpers ({{#t}}Hello{{/t}}) · a helper
anywhere but where its result is written, such as {{#if (t "x")}}.
Runtime template loading. Templates are compiled into your binary, so there is nothing to load and no dynamic partial names. The aim of this project is compile time safety.
{{> row}} includes row.hbs from the same directory. As in handlebars.js, the partial renders
against the context it was included from, so this works with no extra wiring:
templates/row.hbs:
templates/page.hbs:
The partial's variables become part of the including template, so page asks for rows of id and
name — you never name row.hbs in Rust. row.hbs still gets its own type, so it can be rendered
on its own too.
Each template gets a module of its own, named after the file, holding the types it needs. The directory layout becomes the module layout:
templates/page.hbs templates::page::Vars templates::page::RowsItem
templates/admin/row.hbs templates::admin::row::Vars templates::admin::row::builder()
So two templates called row in different directories are two different modules, rather than a
name collision.
Partials are resolved at compile time by splicing, so there is no second render call and no
intermediate String. Editing a partial rebuilds every template that includes it. Cycles, unknown
names and arguments ({{> row this}}, not supported yet) are all compile errors. Partials need a
directory to look in, so they work with directory! and file! but not str!.
{{ name }} HTML-escapes its value and {{{ name }}} does not, as Handlebars specifies. Escaping
covers the same characters handlebars.js escapes - &, <, >, ", ', ` and = - and
happens as the value is written, so nothing is allocated for it.
{{#with person}} renders its block even when person was never set, showing empty fields, where handlebars.js would
skip the block. {{#if}} and {{#unless}} are unaffected — an absent variable is correctly falsy there.
1.88, verified in CI by building both published crates on that toolchain rather than assumed from
the edition — edition 2024 needs only 1.85, but a let-chain in the parser and the trybuild
dev-dependency both need 1.88. Raising the MSRV is treated as a breaking change.
Usage notes · Changelog · Development
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT licence (LICENSE-MIT)
at your option. This is the Rust ecosystem convention: the MIT licence is the permissive default, and Apache-2.0 adds an explicit patent grant that some downstream users need.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 licence, shall be dual-licensed as above, without any additional terms or conditions.
The Handlebars parser is derived from rusty-handlebars — see NOTICE for attribution.