Skip to content

Generate config from typed templates

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:

  1. Write a template file with ${name} (legacy) or {{name}} placeholders.
  2. Declare a type Foo { template = "path/to/file" } in your script.
  3. Construct Foo { name: "value", ... } and lash checks the construction against what the template needs.
  4. Render with Foo { ... }.render() or write directly with write 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.
Terminal window
lash --test gen-units.lash # the unittests only
lash gen-units.lash --help # auto-generated help

The --test flag runs only the unittest { } blocks; the fn main body is skipped, so the tests don’t need to write any files.


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

Three placeholder kinds appear here:

  • ${description}, ${binary}, ${profile} — scalar substitution. Any string-renderable value works.
  • {{#if has_env}}…{{/if}} — emit the body only if has_env is true. lash will require has_env to be a bool; passing an int is an error.
  • {{#each restart_codes as |code|}}…{{/each}} — repeat the body once per item, binding code. lash will require restart_codes to be a list.

There’s no {{else}} and no whitespace-control sigils. Tags are paths, not code.


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.


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 references
unittest {
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 entirely
unittest {
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 missing

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.


Three gates surface shape problems:

GateWhenWhat it catches
1Semantic analysis (before any statement runs)Missing required fields; obvious literal-type mismatches. Halts execution.
2Construction (Name { ... } evaluation)The dynamic cases gate 1 deferred — variable values, function results.
3Render (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 int

For nested data inside a loop, the path identifies the offending position:

template field 'Fleet.environments[0].servers[0].host' is required but was not provided

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 provided

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 = true
let 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"]
}

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 — feeds nfpm for .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.