Typed Templates
A typed template binds a user-defined type to a text template file. Constructing the type renders the template with the type’s fields. lash infers the field set, type-checks each field against the inferred shape, and produces friendly errors before the script runs.
At a glance
Section titled “At a glance”type Greeting { template = "templates/greeting.txt.template"}
let g = Greeting { name: "world", count: 3 }write g to "out/greeting.txt"templates/greeting.txt.template:
hello ${name} x${count}That’s the whole feature surface for the simple case. The remainder of this page covers control flow, error messages, and the rules.
Type binding
Section titled “Type binding”Inside a type declaration, the template clause binds a path:
type Foo { template = "path/to/template.txt"}The path is a literal string. It is resolved relative to the current working directory when Foo { ... } is constructed and rendered. The template file must exist at construction time; otherwise rendering returns an error.
A type with a template clause cannot also declare values or validate. Templates and completion types are distinct uses of type.
Tag forms
Section titled “Tag forms”| Form | Purpose |
|---|---|
${name} | Field substitution. Stringifies any value via valueToString. |
{{name}} | Same as ${name}. Both forms are accepted; pick whichever clashes less with your template’s existing literal syntax. |
{{path.to.field}} | Dotted-path access. Resolves through nested objects, including loop-bound items. |
{{#if cond}}…{{/if}} | Conditional block. Body is emitted when cond is true. |
{{#each list as |item|}}…{{/each}} | Loop block. Body is emitted once per item, with item bound in scope. |
There is no {{else}}, no whitespace-control sigils, no inline expressions. Tags are paths, not code.
Strict typing
Section titled “Strict typing”{{#if X}} requires X to be a bool. Anything else (int, string, list, …) is a hard error.
{{#each XS as |i|}} requires XS to be a list. Anything else is a hard error.
A field referenced as ${name} or {{name}} (outside any block) is treated as a string-renderable scalar — int, float, string, bool all stringify cleanly.
A field referenced as {{x.y}} makes x an object with a y field. Conflicts surface at parse time:
error: template field 'x' used as both string and objectField inference
Section titled “Field inference”The set of fields a typed template needs is inferred from the template body — you don’t list them in the type declaration. Each loop’s element shape is inferred from references inside the loop, including arbitrarily nested loops:
{{#each environments as |env|}} {{env.name}} {{#each env.servers as |srv|}} - {{srv.host}}:{{srv.port}} {{/each}}{{/each}}This produces the inferred shape:
{ environments: list<{ name: string, servers: list<{ host: string, port: string }> }>}A Foo { environments: [ ... ] } literal is checked against this shape recursively.
Validation gates
Section titled “Validation gates”Three gates catch shape mismatches:
| Gate | When it fires | What it catches |
|---|---|---|
| 1 | Semantic analysis (before any statement runs) | Missing required fields; obvious literal-type mismatches like flag: 42 against a bool field. Template-file lookup is best-effort — analysis runs from the script’s current working directory. Errors halt execution. |
| 2 | Construction (TypeName { ... } evaluation) | Anything gate 1 deferred (variable values, function results, dynamic content). Fails immediately with a path-carrying error like Foo.environments[0].servers[0].host. |
| 3 | Render (render() / write … to …) | Final safety net. In practice, gate 2 covers all reachable cases. |
The error message format is consistent across gates:
template field 'TypeName.path.to.field' must be <expected>, got <actual>.render() and .write(stream)
Section titled “.render() and .write(stream)”A typed object exposes two implicit methods:
obj.render()returns the rendered template as astring.obj.write(stream)renders the template and writes the result tostream.
Both are validated against the type’s template at construction, so neither method can fail with a shape error at this point.
write VALUE to "path" shorthand
Section titled “write VALUE to "path" shorthand”Instead of:
write to "out.txt" as f { f.write(formula.render())}write directly:
write formula to "out.txt"The shorthand desugars to a WriteToStmt whose body calls formula.write(__handle) against an internally-bound stream variable. It works for any value that responds to .write(stream), not just typed objects.
The target can also be stdout or stderr:
write formula to stdoutwrite "warning\n" to stderrWhen the target evaluates to a stream object instead of a path, WriteToStmt binds the block variable directly to that stream and skips the file open / close steps.
Whitespace handling
Section titled “Whitespace handling”A block-tag’s own line is consumed entirely:
prefix{{#if x}}body{{/if}}suffixRenders as prefix\nbody\nsuffix\n when x is true. The newlines that terminate the {{#if}} and {{/if}} lines are eaten; the \n that ends the body’s content line is preserved.
There is no {{- … -}} syntax. The trim is always-on and matches what you’d expect from the source layout.
Field renderability
Section titled “Field renderability”valueToString handles every primary lash type. The rendered forms are:
| Lash value | Rendered as |
|---|---|
"hi" | hi |
42 | 42 |
3.14 | 3.14 |
true | true |
[1, 2] | [1,2] |
{"k": "v"} | {"k":"v"} |
Bool fields and list fields used in {{#if}} and {{#each}} are resolved structurally rather than stringified, so the type checks above apply.
Pass-through behavior
Section titled “Pass-through behavior”A ${unknown} or {{unknown}} reference whose name is not in the constructed object’s entries is passed through to the output verbatim. This lets templates contain bash- or RPM-style placeholders (${pkgver}, %{version}) that are not lash field names — they survive rendering as literals.
This is intentional and complements the strict block typing: control flow is enforced; substitution is permissive.
Where this is used today
Section titled “Where this is used today”homebrew/lash-shell.rb.template— Homebrew formula generated per release.nfpm.yaml.template—.deb/.rpm/.pkg.tar.zstpackaging metadata.aur/PKGBUILD.template— Arch User Repository package build script.obs/lash-shell.spec.template— RPM spec for the openSUSE Build Service.obs/lash-shell.dsc.template— Debian source-package description (Format: 3.0 (native)) referencing the renamed Debian tarball.
The release script (release.lash) constructs typed objects against each template and writes the rendered output. Earlier releases used sed chains for the same job; the typed-template feature replaced them.
See the typed-templates tutorial for a walkthrough.