Lesson 04 / 12
Templates
The same template text, run with two different Go types (an integer, a decimal number), produces byte-for-byte identical output; the source's type information is lost entirely at the output. A field name appears in the output only if it was written into the template's own text — it does not come from the type system. Text and HTML templates turn the same data into different byte sequences with respect to escaping. The limiting measure: a template looks up field names at run time, a nonexistent field only becomes known at execution, not at compile time, and the error is born in the middle of the output.
Published
In this lesson
- Shows by running it that the text produced by two different Go types run through the same template is byte-for-byte identical
- Tests that a field name appears in the output only if it was written into the template's own text
- Compares how text and HTML templates diverge on the same data with respect to escaping
- Shows that an expression asking for a nonexistent field stops the template at run time, but the output produced before the stop remains
Contents
The previous lesson’s last measurement showed what a struct tag is for: a struct tag was a schema that carried a field name to the other side of the boundary, and without that schema there was no rebuilding either. This lesson asks the same question in reverse. JSON serialization turned a struct into bytes and brought it back — there was a schema between the outbound trip and the return, and the schema rebuilt the type of five of the four fields. Templates never promise this return at all: what a template produces is only text, and that text is assumed never to be read again with any schema. The course’s question stays the same — what happens to a value’s type information as it crosses a boundary? — but this time the other side of the boundary is not a program, it is a screen or a file.
Go’s standard library carries two template packages: text/template and html/template.
Both use the same template language — {{.Field}} reads a field, {{if}} and {{range}}
provide branching and iteration — and both take a Go value and write text to an
io.Writer. The only difference between them is the escaping behavior measured shortly.
Both are called by their package name in the code blocks; in the prose they are referred to
only as “the text template” and “the HTML template.”
The Execute method’s second parameter is an io.Writer — this lesson uses a buffer
(bytes.Buffer), but the same call could just as well write to a file, standard output, or
a network connection; the System and Data Access topic’s first lesson will separately
measure that the same interface is shared between a file and a buffer. The template package
itself does not know, and does not need to know, what the destination is — it only produces
and writes a sequence of bytes. This lesson measures exactly what does and does not stand
inside these produced bytes: as the source data crosses the boundary, does the type system
stay on Go’s own side, or does it carry through to the text the template produces?
The course’s shared measurement split the thirteen boundary crossings into three classes: type is preserved in one, rebuilt in five, lost in seven (the full distribution built in Lesson 03). Template output is one of these seven lost crossings — but the shape of the loss differs from JSON’s loss, because JSON is a round-trip format (converted to bytes, then readable back), while a template never promises a return at all; it is not even assumed that the text it produces will ever be parsed again.
A Template’s Only Output Type: Text
ND19. What a template’s Execute call produces, no matter what the source data’s type
is, is always a byte sequence — and once that byte sequence is turned into a string, it
carries none of the source’s type information. The most direct way to show this is to run
the same template text with two different Go types and compare the outputs.
// templates.go — measures the type of a template's output, field traces, the escaping difference, and run-time lookup
package main
import (
"bytes"
"fmt"
"html/template"
"strings"
textTemplate "text/template"
)
type Product struct {
Name string
Quantity int
Comment string
}
type ProductInt struct{ Quantity int }
type ProductFloat struct{ Quantity float64 }
func main() {
product := Product{Name: "bolt", Quantity: 12, Comment: "good <b>but</b> expensive & slow"}
fixedTemplate := textTemplate.Must(textTemplate.New("t").Parse("{{.Quantity}}"))
var bufFromInt, bufFromFloat bytes.Buffer
fixedTemplate.Execute(&bufFromInt, ProductInt{Quantity: 12})
fixedTemplate.Execute(&bufFromFloat, ProductFloat{Quantity: 12})
fmt.Println("-- same template, two different source types --")
fmt.Println("output from the int:", bufFromInt.String())
fmt.Println("output from the float:", bufFromFloat.String())
fmt.Println("are the two outputs byte-for-byte the same:", bufFromInt.String() == bufFromFloat.String())
narrow := textTemplate.Must(textTemplate.New("narrow").Parse("{{.Name}} x{{.Quantity}}"))
var bufNarrow bytes.Buffer
narrow.Execute(&bufNarrow, product)
fmt.Println()
fmt.Println("-- a field name appears in the output only if the template's own text wrote it --")
fmt.Println("narrow template output:", bufNarrow.String())
fmt.Println("does \"Name\" appear in the output:", strings.Contains(bufNarrow.String(), "Name"))
fmt.Println("does \"Quantity\" appear in the output:", strings.Contains(bufNarrow.String(), "Quantity"))
wide := textTemplate.Must(textTemplate.New("wide").Parse("Name: {{.Name}}, Quantity: {{.Quantity}}"))
var bufWide bytes.Buffer
wide.Execute(&bufWide, product)
fmt.Println("wide template output:", bufWide.String())
fmt.Println("does \"Name\" appear in the output:", strings.Contains(bufWide.String(), "Name"))
textTmpl := textTemplate.Must(textTemplate.New("m").Parse("{{.Comment}}"))
var bufText bytes.Buffer
textTmpl.Execute(&bufText, product)
htmlTmpl := template.Must(template.New("h").Parse("{{.Comment}}"))
var bufHtml bytes.Buffer
htmlTmpl.Execute(&bufHtml, product)
fmt.Println()
fmt.Println("-- the same data produces different bytes from a text and an html template --")
fmt.Printf("text template: %s\n", bufText.String())
fmt.Printf("html template: %s\n", bufHtml.String())
fmt.Println("are the two outputs the same:", bufText.String() == bufHtml.String())
broken := textTemplate.Must(textTemplate.New("broken").Parse("before: {{.Name}}, after: {{.MissingField}}"))
var bufBroken bytes.Buffer
err := broken.Execute(&bufBroken, product)
fmt.Println()
fmt.Println("-- a nonexistent field errors at run time, the part written before it stays written --")
fmt.Println("did an error occur:", err != nil)
fmt.Printf("part that reached the output before the error: %q\n", bufBroken.String())
}
-- same template, two different source types -- output from the int: 12 output from the float: 12 are the two outputs byte-for-byte the same: true -- a field name appears in the output only if the template's own text wrote it -- narrow template output: bolt x12 does "Name" appear in the output: false does "Quantity" appear in the output: false wide template output: Name: bolt, Quantity: 12 does "Name" appear in the output: true -- the same data produces different bytes from a text and an html template -- text template: good <b>but</b> expensive & slow html template: good <b>but</b> expensive & slow are the two outputs the same: false -- a nonexistent field errors at run time, the part written before it stays written -- did an error occur: true part that reached the output before the error: "before: bolt, after: "
ND20. ProductInt.Quantity is an int, ProductFloat.Quantity is a float64 — when
both are run through the same {{.Quantity}} template, the output is the string 12 in
both cases, byte-for-byte equal. While the template was running it knew what type the source
came from (Execute takes an interface value and reaches its field through reflection), but
no trace of this information remains in the text it produces. A program reading the output
cannot work backward from the string 12 to answer the question “was this an integer or a
decimal?”
This lands on the same surface as the previous lesson’s untyped-container observation, but
by a different route. When a JSON value was decoded into an untyped container, a number
always became float64 — there was a loss, but the loss had a specific direction: every
number fell into the same single type, and that fall was encoding/json‘s own rule.
Template output does not even have a rule like this, because there is no target type at all
— int and float64 both fall to the same string, but this fall has no structure like
“converging on a single target type”; both became ordinary text either way, and the text itself
belongs to no type. JSON’s loss was a converter’s decision; the template’s loss is the
result of there being no target type at all to convert to.
ND21. In the bolt x12 text the narrow template ({{.Name}} x{{.Quantity}}) produces,
neither the word "Name" nor "Quantity" appears — field names are lost just as much as
type information, because what the template writes is the field’s value, not the
field’s name. "Name" does appear in the wide template’s output (Name: {{.Name}}, Quantity: {{.Quantity}}), but not because Go automatically carries the struct field’s name
along — the person who wrote the template typed the string "Name: " into the template by
hand, as plain text. A field name appearing in the output is not a guarantee coming from the
type system, it is a decision made by whoever wrote the template text.
A template’s output being pure text carries a flexibility as much as it carries a
limitation: the destination does not have to be an HTML document. text/template can also
be used to produce a configuration file, an email body, a log line, even a source file
written in another language — whatever is produced, it makes no difference to the template
package’s eyes, because the package only translates one piece of text into another,
substituting field values in place. The course’s next lesson picks up exactly this last use,
source code generation, from precisely this point.
ProductInt and ProductFloat were deliberately defined as two parallel, single-field
types: the only difference between them is the type of the Quantity field, nothing else
changes. This makes clear that what the measurement shows is only “type information is
lost,” not something caused by another structural difference (field count, field order,
field name) — the two types respond to the same template action ({{.Quantity}}) under the
same name, and because type is the only variable between them, the equality in the output
can be attributed directly to that single variable.
The Escaping Difference Between Text and HTML Templates
ND22. The same Product value, with the same {{.Comment}} template text, produces two
different byte sequences when given to two different packages. text/template writes the
<b> and & characters in the Comment field exactly as they are, without touching them at
all. html/template turns the same characters into the strings <, >, and &
— this transformation is escaping (the term established in the M14/K01 Foundations of
the Web and HTML course). html/template does this automatically because it assumes its
output will be placed inside an HTML document, and knows that the characters <, >, &
have a special meaning in HTML; text/template makes no such assumption, because it does
not know where its output is going.
The M14/K01 course built HTML generation and escaping behavior on the browser side, in the
context of processing a document arriving from a server; that is not repeated here, the only
difference is that these two packages stand inside the same standard library, as a
production step on the server side. The choice between the two packages is not a matter of
style: if the output is going into an HTML page, html/template must be used, because a
text template that skips escaping produces an output that writes a string like <script> in
the data exactly as it is, if one appears.
html/template’s escaping works in more detail than it appears in this measurement: the
package detects where in the template a field is used (inside a tag body, an attribute
value, a link address) during parsing, and applies the escaping rule appropriate to each
context — a quotation mark inside an attribute value and a < character inside a tag body
are escaped by different rules. This lesson only measures the simplest context (a tag body);
context awareness itself is a separate subject and is not deepened here. The important
result is this: text/template has no idea in what context its output will be used and
changes no characters at all, while html/template assumes its output will be placed into
an HTML document and behaves accordingly. When unescaped user input is placed as it is
inside an HTML document, the browser may read it not as plain text but as structure —
escaping itself is a step that changes not the appearance of the value passing through, but
how the other side interprets it.
A Field Name Is Looked Up at Run Time
ND23. The expression {{.MissingField}} asks for a field that does not exist on the
Product type. This template compiles — because the template text is a string written in a
separate language Go’s compiler never sees; the compiler knows which fields the Product
type has but never reads the expression {{.MissingField}} inside a string. The field
lookup happens through reflection at run time, when Execute is called, and when the
field cannot be found at that moment, Execute returns an error. A question similar to the
course’s “the counterpart of what was never written” (the axis established in M09/K01, not
reused here) also holds here: a layer the compiler never looks at produces its own error on
its own schedule.
A template’s own error moments are not singular either. The template.Must call wraps the
error Parse returns and panics — this never triggered throughout this lesson, because
every template’s text was syntactically valid (every {{ always closed with a }}, every
action was a recognized action). A Parse error, for an unclosed {{ fragment for example,
comes out right at the start of the program, while the template is being built. An Execute
error, on the other hand, comes out only when the requested field is missing, while the
syntax is entirely valid. Both happen at run time (Go’s compiler sees neither), but one
surfaces the moment the template is built, the other only when it is actually run with real
data — and this lesson only measured the second one, because no example that would trigger
the first was used.
ND24. The broken template carries the text "before: {{.Name}}, after: {{.MissingField}}" and contains two separate actions: first {{.Name}} evaluates
successfully and the text "before: bolt, after: " is written to the bufBroken buffer;
then {{.MissingField}} fails and Execute returns an error. But the buffer is not rolled
back — the part written before the error stays where it is, because Execute writes text
progressively, it does not accumulate a result and write it all at once at the end. This
explains where the string "before: bolt, after: " seen in the measurement’s last line
comes from: the error is born in the middle of the output, while the second field is being
looked up, and everything produced up to that point has already reached the io.Writer.
ND25. This adds a new angle to the course’s distinction between “rejected” and “left
exposed.” The compiler never sees the field name error — the source compiles under every
condition — and the error surfaces neither at compile time nor while the template is being
parsed (in the Parse call); only when Execute runs, and the output written at that
moment of execution becomes permanent. The only way to test a template before putting it
into production is to run it with real data; a tool that statically checks the
correctness of field names falls outside this pair.
The practical consequence of this is that a rarely triggered branch inside a template (a
line that only passes through under a {{if}} condition with specific data) can go months
without ever running at all. The Go Command Family lesson of the Go Fundamentals course
showed that the vet tool catches a pattern the compiler misses — a mismatch between a format
string and an argument’s type. There is no second checker like this for template field
names; the template language itself lives outside Go’s type system, in a separate language,
and runs in a layer neither the compiler nor the vet tool can reach. This is why a template’s
correctness is measured not by the assurance compilation gives, but by the assurance of
having run that template with real data at least once — and this lesson did exactly that, by
running it.
Summary
- What a template produces is always text; no matter what the source’s type is (
int,float64), the same template produces the same string, and there is no way to work backward from the output to the source’s type. - Field names do not automatically appear in the output either — if a name appears in the output, it is because the person who wrote the template typed that name into the template as plain text.
text/templateandhtml/templateshare the same template language, buthtml/templateruns its output through HTML escaping; the same data produces two different byte sequences in the two packages.- A field name in a template is looked up through reflection not at compile time but while
Executeruns; a nonexistent field halts not the template’s compilation but its execution. - The error is born in the middle of the output: the part
Executewrote before returning the error stays permanently in theio.Writer, it is not rolled back.
Next Step
This lesson measured that a template’s output is pure text carrying no type — and that no schema had been set up on the other side to read this text. The difference between this lesson and the course’s previous lesson (JSON serialization) stands exactly here: one is a trip out that returns with a schema, the other is an exit that never returns at all. But the two lessons share a common point: in both, the schema, where it existed, stood on only one side of the boundary — the target type on the receiver in JSON, nobody at all in the template. The next lesson joins these two ends: when two separate sides share the same schema, what happens to the type information crossing the boundary? Contract-based service communication answers this question by building the schema itself in a form that generates both sides’ source — and there it builds the single line, among the course’s thirteen boundary crossings, where type information is preserved.
To keep your progress and take notes, Log in
My notes
Log in to take notes.