Nicer struct literals in Go templates

June 24, 2022

Go templates (text/template, html/template) accept a single argument to render. That’s generally enough when you’re executing them from Go code. But when invoking a template from another template, you often want to pass multiple things to it.

For example, we might have a template that renders a nav bar item. It requires a title, a url, and an “enabled” bool. We want to invoke it from another template.

How do you do that?

In this post I’ll review the existing options and then discuss a new one I’m working on.

Existing options

dict

You can combine multiple key/value pairs into a map. Define a “dict” function in your FuncMap and call it from your template.

This might look like:

{{ template "navbar" dict "title" "Home" "url" (urlNamed "home") "enabled" true }}

This is flexible and gets the job done. But it is a bit of a blob and has no type safety.

Do it all in Go code

Define a nav bar item struct:

type NavBarItem struct {
    Title string
    URL string
    Enabled bool
}

Construct the nav bar item in your Go code using a struct literal. Pass it into your template, and then pass it along to the nested template.

This works, but it splits your content into two places. And it can be annoying to thread everything through. It is not so much a solution to the problem as it is giving up on solving the problem.

tmplfunc

tmplfunc converts templates to functions. If you define the navbar template using {{ define "navbar title url enabled" }}, then tmplfunc arranges for it to be callable:

{{ navbar "Home" (urlNamed "home") true }}

This adds a bit of type safety, but not much. As the list of parameters grows, it loses readability. It only works with template execution; you can’t use it to define a local variable. And it requires that you replace the template parsing pipeline with tmplfunc.

A new approach

The new approach I’m experimenting with is to define a Go struct for the template input (as above) and then autogenerate corresponding FuncMap entries. This provides a template syntax for writing struct literals.

My initial attempt at this is package tstruct.

First, we hook the NavBarItem struct up to the FuncMap:

m := template.FuncMap{ /* your func map here */ }
err := tstruct.AddFuncMap[NavBarItem](m)
// handle err

We can now call the template like this:

{{ template "navbar" NavBarItem (Title "Home") (URL (urlNamed "home")) (Enabled true) }}

This is a bit more verbose, but I find it far more readable. It is pretty type safe, and it is flexible; you can reorder and omit any of the field arguments.

Package tstruct also supports map and slice fields. For example, given this struct type:

type Example struct {
    Map map[string]int
    Slice []int
}

After calling tstruct.AddFuncMap[Example](m), you can write:

{{ $x := Example (Map "a" 1 "b" 2) (Slice 5 6 7) }}

x now contains the value:

Example{
    Map: map[string]int{"a": 1, "b": 2},
    Slice: []int{5, 6, 7},
}

If it helps with clarity, you can also build maps and slices incrementally. This yields identical results:

{{ $x := Example
    (Map "a" 1)
    (Map "b" 2)
    (Slice 5)
    (Slice 6)
    (Slice 7)
}}

You can also define custom setters for struct fields with named types; look for TStructSet in the tstruct readme.

FuncMap name collisions

FuncMaps are global, so if you already have an entry called NavBar, then package tstruct can’t (or rather, won’t) add one to construct NavBar structs. Similarly, tstruct can’t add two structs with the same name, or a struct with the same name as a struct field.

As a special case, however, tstruct supports using the same struct field name across different structs, even if they have different types. (This is possible because the “field setter” FuncMap entry is only fully evaluated by tstruct itself in the context of a specific struct type.)

Fortunately, most FuncMap functions start with a lower case letter, and tstruct only works with exported struct fields, so there’s some amount of convention-based namespacing.

Caveats

As of June 2022, this is fairly novel, both the idea (I think) and the implementation (definitely).

I expect the API and details to evolve as I use this more and (hopefully) hear from others using it. Please file issues with feedback, bugs, and ideas. If it ends up being popular, I’ll try to push towards a stable v1.