Documentation menu

Typed Handlebars

Goldar templates keep HTML visible while giving their inputs a TypeScript contract. In this tutorial, you will declare template props, render values safely, branch and iterate with checked expressions, compose templates, and connect the result to TypeScript.

Declare the template contract

Create src/user-card.hbs with a leading TypeScript frontmatter block:

---
type Props = {
	readonly user: {
		readonly name: string;
		readonly role: string;
	};
};
---

<article>
	<h2>{{user.name}}</h2>
	<p>{{user.role}}</p>
</article>

Both --- delimiters must occupy their own line, and the contract must be a Props type alias. Goldar compiles the file into a callable module whose argument matches that type.

import UserCardView from "./user-card.hbs";

const card = UserCardView({
	user: { name: "Trini", role: "Designer" },
});

After synchronization, TypeScript reports a missing role, an extra property, or the wrong value type at this call site.

Use checked expressions

Template expressions read root props and their properties:

<p>{{user.profile.displayName}}</p>

Goldar escapes ordinary text and attribute expressions. Triple-mustache rendering accepts only a TrustedHtml value created at an explicitly reviewed trust boundary; it is not a shortcut around escaping for normal strings.

The current language is intentionally smaller than general Handlebars. Prefer direct property access and move calculations or application decisions into TypeScript rather than inventing template helpers.

Branch with if

Use if and else for presentation states:

{{#if user.isAdmin}}
	<p>Administrator</p>
{{else}}
	<p>Member</p>
{{/if}}

The expression is checked against Props, and the compiler preserves the branch structure in the render program. unless, subexpressions, and general helper calls are not part of the implemented subset.

Iterate with an explicit binding

Iteration introduces a lexical name instead of silently changing the meaning of this:

{{#if users.length}}
	<ul>
		{{#each users as user}}
			<li>{{user.name}}</li>
		{{/each}}
	</ul>
{{else}}
	<p>No users yet.</p>
{{/if}}

The required as user clause makes the item type visible to the compiler, editor tooling, and the reader. The loop may include an else branch. Implicit this, parent traversal such as ../, indices such as @index, and destructuring are not currently supported.

Compose typed templates

Make the card reusable by importing it from a parent template. Create src/user-list.hbs:

---
import type { User } from "./index.js";
import UserCardView from "./user-card.hbs";

UserCardView.as("user-card");

type Props = {
	readonly users: readonly User[];
};
---

{{#if users.length}}
	<div class="user-list">
		{{#each users as user}}
			<user-card user={{user}} />
		{{/each}}
	</div>
{{else}}
	<p>No users yet.</p>
{{/if}}

UserCardView.as("user-card") gives the import a template-local tag name. This is compile-only frontmatter syntax: Goldar retains the imported template for exact prop checks and removes the .as() declaration from generated TypeScript. There is no global partial registry and no string-based dynamic lookup.

The child call is checked against user-card.hbs’s Props. Missing, unknown, and incorrectly typed props are compiler diagnostics at the call site.

Pass views through a layout

Templates can also accept ordered child views. A layout renders them at one {{outlet}}:

---
type Props = {
	readonly title: string;
};
---

<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<title>{{title}}</title>
	</head>
	<body>
		<main>{{outlet}}</main>
	</body>
</html>

Call the page and pass it to the layout from the route:

export interface User {
	readonly name: string;
	readonly role: string;
}

class UsersRoute extends Route {
	render() {
		const users: readonly User[] = [
			{ name: "Trini", role: "Designer" },
			{ name: "Billy", role: "Engineer" },
		];
		const page = UserListView({ users });
		return LayoutView({ title: "Team" }, page);
	}
}

An outlet does not add a wrapper element. A template may declare at most one outlet.

Keep styles near their markup

A normal style block is compiled with template-local selector scoping:

<style>
	.user-list {
		display: grid;
		gap: 1rem;
	}
</style>

Use <style is:global> when the whole block is intentionally global, or :global(...) around one selector that must escape the local scope. Goldar collects compiled template styles into the generated client stylesheet.

Synchronize and type-check

Goldar has two complementary type-safety layers:

npx goldar sync
npx tsc -p tsconfig.json

goldar sync generates exact .hbs module declarations beneath .goldar/types. TypeScript then checks calls from .ts files into those template modules. tsc does not parse the expressions inside Handlebars files.

Inside a template, the Typed Handlebars language service supplies completion, hover, definition navigation, and diagnostics by mapping the template through a generated TypeScript shadow document. Editor clients can run the typed-handlebars-language-server binary and provide an element registry. See the Typed Handlebars API for that tooling contract.

Describe custom-element attributes

An HTMLElement class does not tell TypeScript which HTML attributes its tag accepts. Language tooling can read an explicit registry:

export interface ElementAttributes {
	readonly "user-avatar": {
		readonly "display-name": string;
		readonly size?: "small" | "large";
	};
}

With that registry, the language service can complete the tag and its attributes, explain them on hover, and diagnose missing, unknown, or invalid values:

<user-avatar display-name={{user.name}} size="large"></user-avatar>

Standard HTML elements remain permissive. The registry is for editor and tooling feedback; the custom element module still owns its browser runtime behavior.

Know the current boundary

Typed Handlebars currently supports root property paths, typed if, explicit each ... as bindings, imported-template props, custom-element contracts, and source mappings that preserve UTF-16 positions. It does not claim full Handlebars compatibility.

For a complete application using these patterns, read the todo-list example. Use the compiler reference for synchronization and build behavior.