Install Β· Quick start Β· The language Β· Diagnostics Β· How it works Β· Reliability Β· Design
You give Temple a template and an input value; it gives you back a value
deserialized straight into your Rust types. A template fixes the shape of the
output, with {{ β¦ }} holes where values are computed:
| Template | Input β Output |
|---|---|
// input
{ "customer": "Ada",
"items": [ { "name": "widget", "qty": 3, "price": 9.99 } ] }
// output β deserialized into your Receipt struct
{ "summary": "Ada ordered 1 item(s)",
"lines": [ { "name": "widget", "total": 29.97 } ],
"subtotal": 29.97, // exact Decimal, never f64
"receipt": "Total due: 29.97" }
// "discount" dropped β `?:` omits null fields |
Compile once, render against millions of inputs β into your
#[derive(Deserialize)] struct, a serde_json::Value, or a dynamic Value.
Note
Pre-1.0, production-shaped. The language, API, and author tooling are complete (milestones 1β8 of 9); 267 tests at ~86% line coverage, benchmarks, a CLI, and CI. The one open milestone is a web editor component. Expect breaking changes only across pre-1.0 versions.
| π― Exact decimals | rust_decimal end to end β 129.99 * 0.0825 is exactly 10.724175. No f64 exists anywhere in the value model. |
| π¦ Typed output | Renders straight into your #[derive(Deserialize)] structs via a custom serde Deserializer β or a dynamic Value with render_value. |
| π‘οΈ Never panics | compile / render / from_bytes return Result on any template or input β checked arithmetic, parser depth & size caps, value-depth cap, iterative drop. |
| β‘ Compile once, render many | Parse Β· validate Β· dependency-graph analysis happen at write time. The render path never re-parses; sub-Β΅s simple renders. |
| π¦ Versioned blobs | to_bytes / from_bytes persist the compiled form as a signature-tagged CBOR blob and reload it without parsing β store it in any BYTEA/BLOB column. |
| π§ Author tooling | Template::format canonicalizes a template; compile / validate report every error at once as rustc-style underlined snippets with did-you-mean hints. |
| πͺΆ Lean core | Five small dependencies; serde_json and the CLI are opt-in behind a feature. No unsafe code. |
[dependencies]
temple-dsl = { git = "https://github.com/sinha-sahil/temple-dsl", branch = "release" }crates.io publishing is wired through CI and lands with the next release. The core library pulls in nothing extra; two opt-in features:
| Feature | Adds |
|---|---|
json |
exact From conversions to/from serde_json::Value β JSON flows straight into render, decimals travel as verbatim digit tokens (never f64) |
cli |
the temple binary (implies json) |
Render into your own types β compile once, render many:
use temple_dsl::{Template, Value};
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Out { id: i64, name: String }
// Err(Vec<CompileError>) β every problem reported at once
let template = Template::compile(r#"{ "id": {{ input.id }}, "name": {{ upper(input.name) }} }"#)?;
let out: Out = template.render(Value::obj([
("id", Value::Int(7)),
("name", Value::Str("ada".into())),
]))?; // Out { id: 7, name: "ADA" }Dynamic output when there's no struct: template.render_value(input) -> Result<Value, RenderError>.
(render::<Value> is deliberately a compile error β use render_value, it skips the serde round-trip.)
JSON in, JSON out β with the json feature there's no manual conversion:
let input: serde_json::Value = serde_json::from_str(request_body)?;
let out = serde_json::Value::from(template.render_value(input)?);
// decimals stay exact in both directions β verbatim digits, never f64Persist the compiled form and skip parsing at render time:
let blob: Vec<u8> = template.to_bytes(); // β store in any BYTEA/BLOB column
let loaded = Template::from_bytes(&blob)?; // deserialize + re-validate, no parse
let out: Out = loaded.render(input)?;Every fallible call returns a structured error (CompileError, RenderError,
LoadError) β Temple never panics and never decides failure handling for you.
A .temple document is an optional let preamble followed by one output
literal. Bare {{ expr }} holes yield a typed value; quoted "β¦ {{ expr }} β¦"
holes interpolate into a string. Entries are separated by commas or
newlines; trailing commas are always fine; # starts a comment.
| Feature | Looks like |
|---|---|
| Paths & access | input.cart.subtotal Β· input.items[0] Β· obj["key"] Β· input.coupon?.code ?? "none" |
| Operators | + - * / % Β· == != < <= > >= Β· && || ! Β· cond ? a : b |
when guards |
when { score >= 90: "A", score >= 80: "B", else: "C" } |
let + this |
preamble let, plus this.total reads a sibling key β order-free, cycle-checked at compile time |
let β¦ in β¦ |
local bindings anywhere: let rate = β¦ in subtotal * rate (incl. inside when) |
| Array methods | .map .filter .fold .sum .any .all .count .find .contains .index_of .sort .sort_by .reverse .unique .flatten .flat_map .take .drop .slice .join .min .max .avg .first .last .concat .len |
| String methods | .contains .starts_with .ends_with .replace .split .slice .index_of .len |
| Object methods | .keys .values .entries .has .get .merge |
| Lambdas | x -> x * 2 Β· (acc, x) -> acc + x β bounded iteration only, never Turing-complete |
| Constructors | array [a, b], object { "k": expr }, computed keys { [expr]: v }, omit-if-null "k"?: expr |
| Built-in functions | abs round floor ceil min max upper lower trim to_string to_number concat type_of is_* json_encode url_encode base64 |
Methods chain on any expression β [3, 1].sort(), f(x).replace(β¦),
(a.concat(b)).len() β not just paths. Put together, that covers real work like
building an HTTP request with computed headers and conditional fields:
{
"url": {{ concat("https://", input.host, "/v1/rules") }},
"headers": { "Authorization": {{ concat("Bearer ", input.token) }} },
"trace_id"?: {{ input.trace_id }}, # absent when null
"by_category": {{ input.items.fold({}, (acc, it) -> # group-by via computed keys
acc.merge({ [it.cat]: (acc.get(it.cat) ?? 0) + it.qty })) }}
}See DESIGN.md for the complete spec and samples/
for worked templates β every shipped sample is compile-tested in CI.
compile and validate report all problems in one pass β parser recovery
plus resolver collection β and each error renders as an underlined snippet with
line/column and a did-you-mean hint when a name is a near-miss:
error: unknown identifier 'inputt' β did you mean `input`?
--> 2:15
|
2 | "total": {{ inputt.cart.subtotal }},
| ^^^^^^
error: unknown function 'rouns' β did you mean `round`?
--> 3:15
|
3 | "tier": {{ rouns(this.total) }}
| ^^^^^
Programmatic access: every error carries a Span; CompileError::report(src)
renders one snippet, CompileError::report_all(src, &errs) renders the batch.
Template::format(src) re-emits any parseable template in the canonical
layout β deterministic and idempotent, ready for format-on-save.
The feature-gated temple binary renders a template against a JSON input, or
prints its canonical formatting:
cargo install --path . --features cli
temple order.temple input.json # render β JSON on stdout
temple fmt order.temple # canonical formatting on stdoutCompile errors print as the underlined snippets above; exit codes are
script-friendly (0 ok, 1 error, 2 usage).
A template is compiled once, at save time β parsing, validation, and dependency-graph analysis all happen there. The render path loads the compiled artifact and evaluates it without re-parsing, so render latency is bounded by construction, not by template size at request time.
sequenceDiagram
participant App as Application
participant Temple
participant Store as Storage
Note over App,Store: Write time β once, when a template is saved
App->>Temple: compile(source)
Temple-->>App: Template
App->>Store: persist source + to_bytes() blob
Note over App,Store: Render time β on every request
App->>Store: load compiled blob
Store-->>App: blob
App->>Temple: from_bytes(blob)
Temple-->>App: Template (no parsing)
App->>Temple: render(input)
Temple-->>App: Ok(value) or Err(RenderError)
The blob is a signature-tagged, versioned CBOR encoding of the compiled module;
from_bytes re-runs validation on load, so a corrupt or version-stale blob is
rejected (recompile from the stored source) β never rendered.
Tree-walking evaluator, measured with cargo bench (Criterion, release):
| Benchmark | Time |
|---|---|
compile_small β 3-field template |
~0.8 Β΅s |
compile_big β ~30 fields, 4 levels deep |
~8 Β΅s |
render_small |
~0.5 Β΅s |
render_big β 30 fields, decimals, struct round-trip |
~5.5 Β΅s |
| compile + render β cold path | ~1.4 Β΅s |
Render cost scales with template size plus the input elements a render
visits β a large passive input subtree adds nothing. A let β¦ in β¦ binding
is an O(1) scope overlay, and lambdas flatten their scope once per collection
walk, never per element. Memory: a compiled template holds ~15Γ its source in
RAM (β1.5 KB for a small one); a render allocates ~1 KB transient.
The crate is built around a no-panic guarantee: compile, render, and
from_bytes return typed errors on any template and any input.
- Enforced structurally β checked arithmetic everywhere, parser depth cap,
1 MiB source cap, render-time value-depth cap, and an iterative
Dropso even dropping a pathologically deep value cannot overflow the stack. - 268 tests, ~86% line coverage β feature-named suites plus a kitchen-sink test that exercises every operator, method, builtin, and structural feature in one template, asserted value-exactly and pushed through the blob and formatter round-trips.
- Thrash-tested β fuzz-style sweeps over malformed sources, deep nesting, overflow inputs, and corrupted blobs (~245k cases at last full run).
- Round-trip invariants under test β
formatis idempotent and its output always re-parses;to_bytesβfrom_bytesrenders identically to the original. - Deterministic β no I/O, clocks, or randomness in the language by design: same template + input β same output, always.
Temple needs four things at once. Existing crates each miss at least one:
| Crate | Sub-ms render | Exact decimals | Typed Rust output | Focused surface |
|---|---|---|---|---|
minijinja |
β | β | β | β |
jaq |
β | β | β | β |
jsonata-core |
~ | β | β | β |
liquid_json |
~ | β | β | ~ |
| Temple | β | β | β | β |
β yes Β· ~ partial Β· β no. Evaluated for Temple's specific needs β not a general verdict on these crates.
The language, API, and author tooling are complete. The single remaining
milestone is M9 β a web editor component: a browser widget that compiles
the same engine to WASM and provides intellisense, inline diagnostics, and
format-on-save out of the box. Plan: M9-EDITOR.md.
Full capability matrix β milestones 1β8 of 9 done
| Capability | Status |
|---|---|
| Object / array / scalar output, value literals | β |
Bare-hole expressions, path access (input.a.b.c) |
β |
Comments (#), trailing commas, newline-separated entries |
β |
Decimal-correct arithmetic via rust_decimal |
β |
Typed output β render::<T> (serde) or render_value (dynamic Value); render::<Value> is a compile error |
β |
Compile once, render many (in-memory Template) |
β |
Result everywhere, no panics β checked arithmetic, parser depth/size caps, value-depth cap, iterative drop |
β |
Operators (+ - * / %, comparison, logical, unary -/!, parens) |
β |
Conditionals (when guards, ternary ?:, short-circuit &&/||) |
β |
Safe access ?. and nullish coalesce ?? |
β |
let preamble, this self-reference, compile-time DAG (cycle detection) |
β |
Core methods (map/filter/fold/sum/any/all/len/first/last/concat), lambdas, arr[i] indexing |
β |
Array stdlib: count/find/contains/index_of/sort/sort_by/reverse/unique/flatten/flat_map/take/drop/slice/join/min/max/avg |
β |
String methods: contains/starts_with/ends_with/replace/split/slice/index_of |
β |
Object methods + indexing: keys/values/entries/has/get/merge, obj["k"] |
β |
% operator; to_number, type_of/is_*, concat, json_encode, url_encode, base64 |
β |
Constructors: array [β¦], object { "k": expr }, computed keys { [expr]: v }, omit-if-null "k"?: |
β |
let β¦ in β¦ local bindings; method/index chains on any expression |
β |
String interpolation in quoted holes β "Hi {{ input.name }}" (incl. \{ escape) |
β |
Built-in scalar functions (abs β¦ to_string) |
β |
validate(src) author-time check; size/depth caps (early rejection) |
β |
to_bytes / from_bytes β versioned compiled blob, reload without reparsing |
β |
Template::format β canonical, idempotent pretty-printer |
β |
| Diagnostics β multi-error reporting with parser recovery, rustc-style underlined snippets, did-you-mean | β |
| Milestone 9 β web editor component (intellisense, inline diagnostics, format-on-save) | π‘ planned |
| Path | What's there |
|---|---|
src/ |
The library + the feature-gated temple CLI binary |
DESIGN.md |
Full language spec, decisions, open questions |
IMPLEMENTATION.md |
As-built map: file layout, sequence diagram, design choices |
M9-EDITOR.md |
Plan for the milestone-9 web editor component |
CAPABILITY-GAPS.md |
The feature-expansion record β what was added and why |
FAQ.md |
How the engine actually works inside |
samples/ |
Canonical .temple templates, one per pattern (compile-tested) |
examples/ |
Runnable Rust demos against the public API |
benches/ |
Criterion benchmark suite |
e2e/ |
Out-of-crate end-to-end (Postgres) harness |
cargo test --all-features # full suite (267 tests)
cargo bench # Criterion benchmarks
cargo llvm-cov --all-features # coverage reportcargo fmt + cargo clippy -D warnings are enforced by a pre-commit hook
(installed automatically via cargo-husky) and by CI on the release branch.
Sahil Sinha Β· sahilsinha.dar@gmail.com
Dual-licensed under MIT OR Apache-2.0 β use it under either at your option.
let items = input.items { "summary": "{{ input.customer }} ordered {{ items.len() }} item(s)", "lines": {{ items.map(it -> { "name": it.name, "total": it.qty * it.price }) }}, "subtotal": {{ items.map(it -> it.qty * it.price).sum() }}, "discount"?: {{ this.subtotal >= 100 ? round(this.subtotal * 0.1, 2) : null }}, "receipt": "Total due: {{ this.subtotal }}" }