Skip to content

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.

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.

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.

FormPurpose
${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.

{{#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 object

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.

Three gates catch shape mismatches:

GateWhen it firesWhat it catches
1Semantic 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.
2Construction (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.
3Render (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>

A typed object exposes two implicit methods:

  • obj.render() returns the rendered template as a string.
  • obj.write(stream) renders the template and writes the result to stream.

Both are validated against the type’s template at construction, so neither method can fail with a shape error at this point.

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 stdout
write "warning\n" to stderr

When 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.

A block-tag’s own line is consumed entirely:

prefix
{{#if x}}
body
{{/if}}
suffix

Renders 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.

valueToString handles every primary lash type. The rendered forms are:

Lash valueRendered as
"hi"hi
4242
3.143.14
truetrue
[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.

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.

  • homebrew/lash-shell.rb.template — Homebrew formula generated per release.
  • nfpm.yaml.template.deb / .rpm / .pkg.tar.zst packaging 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.