Skip to content

ADR-017: Templ Adoption for Type-Safe Templates

Date: 2026-04-04

Accepted (supersedes ADR-008; amends the rendering sections of ADR-007 and ADR-012)

ADR-008 chose Go’s html/template for UI rendering. While functional, this approach had significant drawbacks at scale:

  • No compile-time safety. Template errors (missing fields, wrong types) only surface at runtime, often in production.
  • Stringly-typed data passing. Handlers pass map[string]interface{} — no IDE autocompletion, no refactoring support, no type checking.
  • No component model. Partials are included via string names ({{template "name" .}}), making dependency tracking and dead-code detection impossible.
  • Fragment rendering bug. The old RenderTemplate always executed the base layout, even for HTMX partial responses, sending full-page HTML where only a fragment was needed.

Replace html/template with templ for all server-rendered HTML.

internal/view/
├── layouts/base.templ # Base layout (header, footer, dark mode, toasts)
├── pages/*.templ # Full-page components (wrap in @layouts.Base)
├── partials/*.templ # HTMX fragment components (no layout wrapper)
├── render.go # view.Render(w, r, status, component)
├── props.go # BaseProps + NewBaseProps helper
├── helpers.go # HTMX request/response utilities
└── models.go # Shared view models (PatternSection, QuizScore, etc.)
  • Typed props: Every page defines a props struct embedding view.BaseProps. Handlers construct props with concrete types — no map[string]interface{}.
  • Layout composition: Pages call @layouts.Base(props.BaseProps) { children }. Partials render standalone (no layout wrapper) for correct HTMX fragment responses.
  • Single render path: view.Render(w, r, status, component) handles all responses. Handlers choose between full-page and partial components based on view.IsHTMXRequest(r).
  • Compile-time validation: templ generate produces Go code. Type mismatches and missing fields are caught by go build, not by users in production.
  • web/templates/ directory (all .html files)
  • webutil.RenderTemplate and RenderTemplateWithErrors functions
  • webutil.FormErrors type
  • view.TemplateFuncs() function map
  • HTMX helpers (SetHXTrigger, SetHXRedirect, etc.) — now in internal/view/helpers.go
  • webutil context helpers (GetUserFromContext, GetUserRepoFromContext)
  • HTMX + Alpine.js frontend architecture (unchanged)
  • All template errors are now compile-time errors.
  • IDE support (autocompletion, go-to-definition, rename) works across templates.
  • HTMX partial responses are correct — fragments render without the base layout.
  • templ generate adds a build step, integrated into CI and Taskfile.
  • Developers must learn templ syntax (minimal — it’s Go with HTML).
  • Testable consequences:
    • TC-1: No file imports html/template.
    • TC-2: No map[string]interface{} inside internal/view — props are typed structs.
    • TC-3: Generated templ output compiles.
  • Checks:
    • TC-1 → adr017-no-html-template in scripts/adrcheck (status: block, promoted 2026-09-30)
    • TC-2 → adr017-typed-view-props in scripts/adrcheck (status: block, promoted 2026-09-30)
    • TC-3 → templ generate + go build in task ci (status: block, pre-existing)
  • Not machine-checkable: Single-render-path discipline (view.Render everywhere) and partial/page composition judgment.
  • Amendment 2026-08-19 (#90): TC-4 — committed *_templ.go equals what templ generate produces from the .templ sources (stale generated files with no source are flagged too) → task check:generated (scripts/checkgenerated) in task ci (status: block since 2026-09-30 via -mode=block in Taskfile.yml). Closes the ADR-033 TODO.
  • Graduation log:
    • 2026-09-30 — adr017-no-html-template and adr017-typed-view-props promoted warn → block (ADR-033 §4): clean since 2026-07-12 (80 days, zero false positives; the rule asks for 7). checks/enforcement.config.json carries graduated: 2026-09-30. Demotion back to warn is allowed with the same trail.
    • 2026-09-30 — task check:generated promoted warn → block (-mode=block in Taskfile.yml, ADR-033 §4): clean since 2026-08-19 (42 days). It caught the real templ-CLI drift class the Docker builder had (CHANGELOG 0.9.0) before it was wired, which is the other promotion trigger.