Skip the navigation

Go templates: the bad parts

Published on

1,854 words

Table of contents

Hugo is a fascinating piece of software. Unlike other static site generators which either give you a template engine and bare minimum functionality you need to get along (like, say, Zola) or straight up let you execute arbitrary JavaScript code (like, say, 11ty), Hugo is different — it gives you a template engine and an absurdly wide range of features on top of it, basically extending it into a real dynamic programming language with list processing, string manipulation, and text formatting. This is why I love it — it gives you a lot of freedom without the responsibility of not falling down the npm rabbit hole.

A major reason why this is possible is Hugo's template engine of choice — Go templates. They let you simply hook up a bunch of structures and functions to it and make Go's runtime reflection magic handle the rest. In no uncertain terms, they turn Go, a compiled language with a rich package ecosystem, into a scripting language — all you need to do is expose the things you want to the user. As a basic concept, this is groundbreaking, and Hugo takes full advantage of it, giving you nice stuff like image processing capabilities, filesystem traversal capabilities, the ability to read image Exif metadata.

Unfortunately, Go templates are also full of design flaws and missed opportunities that hold it back from its true potential, which I slowly discovered while using Hugo for my websites over the course of the past few years. Here they are, roughly in the order of egregiousness:

Bad design

Section titled 'Bad design'

0 == 0.0 is false

Section titled '0 == 0.0 is false'
{{/* Throws no error, prints false. */}}
{{ eq 0 0.0 }}

Now, I know how bad this looks at first glance — it truly feels like Go's authors are trolling you. Especially if your first glance at this issue is after hours of debugging why your if statement fails to catch if a variable is equal to 0, like what happened to me. But hold on with me for a moment, there's actually a technical reason for it. Sort of.

Go is a strongly typed programming language, meaning that if you try to perform an arithmetic operation on 2 different types or pass one type where another one is expected, it'll scream at you instead of coercing your value to the right type. When it comes to numbers, it's even stronger than other strongly typed languages like Python, and it won't coerce an integer to a float. Take the following code, for instance:

a := 0
b := 0.0

fmt.Println(a == b)

If you tried to compile it, Go would notice that the variable a is an integer and the variable b is a float, and rightfully tell you that you can't do it:

invalid operation: a == b (mismatched types int and float64)

Go templates inherit this property of being strongly typed. Unlike Go, however, which is statically typed, Go templates are dynamically typed, meaning that the same variable can be reassigned to a value of a different type, which makes sense for a tiny template language. As such, instead of reporting an error, like a strong static language would, or coercing the values, like a weak language would, it silently returns a false because the types don't match.

Except it's not quite that simple. Go does, in fact, coerce the values, but only if both operands are literals, not variable identifiers:

// Prints true
fmt.Println(0 == 0.0)

Go templates, on the other hand, don't perform coercion even in this case and always treat the operands like Go would treat variable identifiers, leading to complete chaos where Go templates' behavior doesn't match Go's behavior.

Variable assignment is a footgun

Section titled 'Variable assignment is a footgun'

This is another case where Go templates do what Go does but in an inconvenient way. As far as variables go in Go (pun intended), it's about as follows:

On its own, := used for variable declaration and = used for variable assignment sounds like a bad idea — it'd be really easy to mix up the two of them and end declaring a new scoped variable instead of assigning to an existing one, and you wouldn't use the var identifier = value syntax out of the need to conform to the agreed upon code style.

But fear not! Go has us covered here. If you do mess up, you'll most likely create a variable that's declared and then never used, which Go will scream at you about and not let you compile your project:

declared and not used: identifier

What do Go templates do in this case? Nothing:

{{ $meal := .PotatoChips }}

{{ if $isBeforeMidnight }}
    {{/* Throws no error. */}}
    {{ $meal := .BuyFood "healthy" }}
{{ end }}

{{/* Always .PotatoChips */}}
{{ $meal }}

And you can't even use the var identifier = value syntax here because it doesn't exist in Go templates.

There's nil but no nil literal

Section titled 'There's nil but no nil literal'

This one is self-explanatory: In Go templates, nil exists as a keyword but doesn't exist as a literal. You can compare values to nil but can't assign a variable to it without resorting to hacks:

{{ $dict := dict }}
{{ $nil := index $dict "non-existent key" }}

{{/* Prints <nil>. */}}
{{ print $nil }}

{{/* Prints <nil>. */}}
{{ if eq $nil nil }}
    {{ print $nil }}
{{ end }}

{{/* Throws an error. */}}
{{ $notNil := nil }}

To be completely fair, the practical impact of this is negligible — it's at most a nuisance that you can't set a variable to an undefined value in the few cases where you need it. But from a language design standpoint, this is extremely cursed. An unrepresentable primitive is not a thing you see often in other languages.

There's no ternary operator

Section titled 'There's no ternary operator'

Let's say you want to have a variable that has 1 of 2 possible values, picked depending on whether a certain condition is satisfied. In Hugo, you can do it like this:

{{ $meal := cond $isBeforeMidnight
    (.BuyFood "healthy")
    .Fridge.Contents.Extract
}}

There are 2 problems with this: First, you can only do it in Hugo. cond is not a thing in Go templates by default. Second, there's a bug in this code. Since Hugo couldn't add a new keyword to the language, they had to make cond a function. And since functions don't do short circuit evaluation like, say, an and keyword, cond will evaluate both possible values regardless of the condition, leading to hard to diagnose side effects.

There's only one way to work around this — use an if statement:

{{/* No nil, baby! */}}
{{ $meal := false }}

{{ if $isBeforeMidnight }}
    {{ $meal = .BuyFood "healthy" }}
{{ else }}
    {{ $meal = .Fridge.Contents.Extract }}
{{ end }}

The difference in the number of braces speaks for itself.

Missed opportunities

Section titled 'Missed opportunities'

Pipes are inflexible

Section titled 'Pipes are inflexible'

Go templates have a nifty feature that lets you chain function calls without having to wrap everything in a million parentheses or making a variable for every intermediate value:

{{ $meal := $potatoes | boil | mash | stickIn $stew }}

Is syntactic sugar for:

{{ $meal := stickIn $stew (mash (boil $potatoes)) }}

You may already see a problem with this: It always passes the value as the last argument to the function. Let's say that it turns out the function stickIn accepts the arguments in the opposite order. Now you have to change the code above to:

{{ $meal := stickIn ($potatoes | mash | boil) $stew }}

And if you then want to call a method on the resulting value, it gets even worse:

{{ $meal := (stickIn ($potatoes | mash | boil) $stew).Serve }}

Compare this to the ES proposal for pipes in JavaScript that uses placeholders for argument placement instead:

const meal = potatoes
    |> mash(%)
    |> boil(%)
    |> stickIn(%, stew)
    |> %.serve();

Much better. This is how pipes should be done, if you ask me.

Templates can't return a value

Section titled 'Templates can't return a value'

Go templates let you define and call inline templates in your code, and I can't overstate how awesome this is. For example, if your template host provides you with some rudimentary reflection capabilities, which Hugo does, you can use it to print lists with recursion:

{{ define "print-slice" }}
    [

    {{ range . }}
        {{ if reflect.IsSlice . }}
            {{ template "print-slice" . }}
        {{ else }}
            {{ . }}
        {{ end }}
    {{ end }}

    ]
{{ end }}

{{ template "print-slice" (slice 9 8 7 (slice 7 6 5) 4 32 true) }}

But unfortunately, printing is the only way you can output values in vanilla Go templates. Hugo does have a workaround for this — inline partials — but it's not without its flaws. Let's say you want to make a function that calculates the factorial of a number:

{{ define "partials/factorial" }}
    {{ return (math.Product (seq 1 .)) }}
{{ end }}

{{ partial "factorial" 5 }}

This works correctly but only for positive values because seq will start iterating backwards for negative values. If you try to fix it the naive way:

{{ if gt . 1 }}
    {{ return (math.Product (seq 1 .)) }}
{{ else }}
    {{ return 1 }}
{{ end }}

You'll get an inscrutable error like this:

execute of template failed at <return>: wrong number of args for return: want 0 got 1

Because it doesn't work for the same reason cond can't short circuit evaluate its arguments — return is just a function. Since it's not a keyword, it can't preemptively stop the execution of a function. All it can do is set a value somewhere and tell Hugo to later put it in the right place. So instead, you have to do this:

{{ $result := 1 }}

{{ if gt . 0 }}
    {{ $result = math.Product (seq 1 .) }}
{{ end }}

{{ return $result }}

Go templates were this close to having user definable functions, but they didn't add a return keyword. What a shame.

Conclusion

Section titled 'Conclusion'

If you're writing a new template engine, it'd be really cool if you added the things Go templates do right without adding the things they do wrong. Just sayin'.