Generate config from typed templates
What’s a typed template?
Section titled “What’s a typed template?”A template is a text file with placeholders. A typed template
goes one step further: the placeholders are tied to a type
declaration in your script, and lash type-checks the values you pass
in before rendering — so when you generate a config file, you can
trust that every placeholder you wrote is filled in with a value of
the right shape.
The flow is:
- Write a template file with
${name}(legacy) or{{name}}placeholders. - Declare a
type Foo { template = "path/to/file" }in your script. - Construct
Foo { name: "value", ... }and lash checks the construction against what the template needs. - Render with
Foo { ... }.render()or write directly withwrite Foo { ... } to "out.txt".
This replaces the “render config with sed” pattern that has lived
in shell scripts forever, and replaces it with something the language
can actually verify. If you typo a field name, you get an error
before the script runs. If you pass an int where the template
needed a bool (because it’s used in {{#if}}), same.
Generate systemd unit files for a fleet of services. Each service has a name, a binary path, an optional environment override, and a list of restart-on-exit codes. By the end you’ll have used:
type Name { template = "..." }— bind a template to a type.Name { ... }— construct a typed value.${field}and{{field}}— substitution.{{#if}}and{{#each}}— control flow inside templates.write VALUE to "path"— the one-liner that renders and writes.- The three validation gates that surface shape errors before render.
lash --test gen-units.lash # the unittests onlylash gen-units.lash --help # auto-generated helpThe --test flag runs only the unittest { } blocks; the fn main
body is skipped, so the tests don’t need to write any files.
Step 1: Sketch the template file
Section titled “Step 1: Sketch the template file”Create templates/service.unit.template:
[Unit]Description=${description}
[Service]ExecStart=${binary}{{#if has_env}}Environment="LASH_PROFILE=${profile}"{{/if}}{{#each restart_codes as |code|}}RestartPreventExitStatus={{code}}{{/each}}
[Install]WantedBy=default.targetThree placeholder kinds appear here:
${description},${binary},${profile}— scalar substitution. Any string-renderable value works.{{#if has_env}}…{{/if}}— emit the body only ifhas_envistrue. lash will requirehas_envto be abool; passing anintis an error.{{#each restart_codes as |code|}}…{{/each}}— repeat the body once per item, bindingcode. lash will requirerestart_codesto be alist.
There’s no {{else}} and no whitespace-control sigils. Tags are
paths, not code.
Step 2: Bind the template to a type
Section titled “Step 2: Bind the template to a type”In gen-units.lash:
type ServiceUnit { template = "templates/service.unit.template"}You don’t list the fields. lash reads the template at parse time, walks it once, and infers the shape:
ServiceUnit = { description: string, binary: string, has_env: bool, profile: string, restart_codes: list<string>}That inferred shape is the contract. Any ServiceUnit { ... }
literal is checked against it before the script runs.
Step 3: Test the typed-template pipeline
Section titled “Step 3: Test the typed-template pipeline”Strict typing isn’t worth much if you can’t actually verify the errors fire when expected. Write a unittest that constructs a value and renders it — that’s the happy path — and then a comment block to show what failure looks like.
/// ServiceUnit renders all fields the template referencesunittest { let unit = ServiceUnit { description: "Lash daemon", binary: "/usr/local/bin/lashd", has_env: true, profile: "production", restart_codes: ["0", "1", "143"] } let rendered = unit.render() rendered.must.contain("ExecStart=/usr/local/bin/lashd") rendered.must.contain("Environment=\"LASH_PROFILE=production\"") rendered.must.contain("RestartPreventExitStatus=0") rendered.must.contain("RestartPreventExitStatus=143")}
/// has_env=false drops the Environment line entirelyunittest { let unit = ServiceUnit { description: "Lash daemon", binary: "/usr/local/bin/lashd", has_env: false, profile: "", restart_codes: [] } let rendered = unit.render() rendered.contains("Environment=").must.equal(false) rendered.contains("RestartPreventExitStatus").must.equal(false)}The second unittest is the interesting one: it’s exercising the
{{#if has_env}} block on the falsy side and the {{#each restart_codes}} block on the empty-list side. Both should produce
nothing, and the test pins that down.
If you typo a field — say, restart_code instead of restart_codes
— lash refuses to run:
[error] template field 'ServiceUnit.restart_codes' is required but missingStep 4: Wire it into a main
Section titled “Step 4: Wire it into a main”The runtime body takes a service name and writes its unit file. We
don’t unittest main — it touches the filesystem. The typed-template
construction above is what gets tested.
fn main(out_dir: string = "/tmp/units") { type ServiceUnit { template = "templates/service.unit.template" }
mkdir -p $out_dir
let services = [ { name: "lashd", binary: "/usr/bin/lashd", profile: "production" }, { name: "lash-monitor", binary: "/usr/bin/lash-monitor", profile: "monitor" } ]
for svc in services { let unit = ServiceUnit { description: "lash service: ${svc["name"]}", binary: svc["binary"], has_env: true, profile: svc["profile"], restart_codes: ["0", "1", "143"] } write unit to "${out_dir}/${svc["name"]}.service" echo "wrote ${svc["name"]}.service" }}write unit to "..." is the shorthand for:
write to "..." as f { unit.write(f)}The shorthand reads as a single declarative line — render this typed value, write it to that path. The block form is still there when you need direct file-handle control.
Step 5: Errors before any statement runs
Section titled “Step 5: Errors before any statement runs”Three gates surface shape problems:
| Gate | When | What it catches |
|---|---|---|
| 1 | Semantic analysis (before any statement runs) | Missing required fields; obvious literal-type mismatches. Halts execution. |
| 2 | Construction (Name { ... } evaluation) | The dynamic cases gate 1 deferred — variable values, function results. |
| 3 | Render (render() / write … to …) | Final safety net. |
All three gates produce the same error format with a path:
template field 'ServiceUnit.has_env' must be bool, got intFor nested data inside a loop, the path identifies the offending position:
template field 'Fleet.environments[0].servers[0].host' is required but was not providedNested loops and dotted access
Section titled “Nested loops and dotted access”Scaling from one service to a fleet uses nested loops in the template:
{{#each services as |svc|}}[Unit]Description=${svc.description}
[Service]ExecStart=${svc.binary}{{#each svc.restart_codes as |code|}}RestartPreventExitStatus={{code}}{{/each}}{{/each}}The outer loop binds svc. Inside the loop, svc.binary is a
dotted-path read on the current iteration’s object. The inner loop
iterates svc.restart_codes — also resolved against svc.
lash infers nested shapes accurately:
Fleet = { services: list<{ description: string, binary: string, restart_codes: list<string>}> }Construction looks the same as before, just with the list spread one level deeper:
let cfg = Fleet { services: [ { description: "lashd", binary: "/usr/bin/lashd", restart_codes: ["0", "1"] }, { description: "lash-monitor", binary: "/usr/bin/lash-monitor", restart_codes: ["0"] } ]}write cfg to "/tmp/fleet.units"If any object in the services list is missing description, the
error names the path:
[error] template field 'Fleet.services[0].description' is required but was not providedJS-style object shorthand
Section titled “JS-style object shorthand”When your locals already match the field names, the JS-style
shorthand { name } desugars to { name: name }:
let description = "Lash daemon"let binary = "/usr/local/bin/lashd"let profile = "production"let has_env = truelet restart_codes = ["0", "1", "143"]
let unit = ServiceUnit { description, binary, profile, has_env, restart_codes}Mix shorthand and explicit:
let unit = ServiceUnit { description, binary, has_env: true, profile: "production", restart_codes: ["0", "1", "143"]}Real-world: the lash release pipeline
Section titled “Real-world: the lash release pipeline”Every typed-template feature in this tutorial earns its keep on every lash release. Five rendered files come from typed templates:
homebrew/lash-shell.rb— Homebrew formula.nfpm.yaml— feedsnfpmfor.deb,.rpm,.pkg.tar.zst.aur/PKGBUILD— Arch User Repository build script.obs/lash-shell.spec— RPM spec for openSUSE Build Service.obs/lash-shell_${version}.dsc— Debian source-package description with embedded md5/sha1/sha256 of the source tarball.
The full pipeline lives in
release.lash.
Each write Type { ... } to "..." line replaces a multi-line sed
chain that the previous version of the script used. The typed
templates also catch a class of release bugs that sed never could: a
typo in a field name, a forgotten value, a wrong type — all surface
immediately, with file and field path, before any package gets built.
Where to go next
Section titled “Where to go next”- The full grammar and validation rules: Typed Templates reference.
- User-defined types in general: Types and Values.
- The
write to ... as f { ... }block form, when you need direct file-handle control: File output.