easy metaprogramming in go
First of all, I want to preface all of this by saying that I really like the go way. It’s so comforting to have a reliable tool that barely changes over years. It’s very straightforward to read and debug compared to other languages.
But there has always been this nagging feeling that there’s problems in go that are tedious and error prone to maintain.
Go “lack” of enums is a very common criticism of go. It’s tedious to write code that marshals and unmarshals enums, not because it’s hard, but because it adds another thing in my todo list each time I refactor code. AI might not forget, but you still need to check that AI has correctly handled all cases, which it might or might not.
There have been already some approaches to metaprogramming in go. The most popular way is to make a whole separate cli that uses the go parser from the standard library and outputs code to the path you want.
This works, but it’s also very tedious. You can take a look at how the Metago scanner works so you can get a feel of the kind of parsing code that you end up with.
I don’t think I should need to do all of that to generate a string method on a type.
Here’s how the minimal version looks like in metago:
{{ define "std.stringer" }}
{{- if not (isPrimitive .) }}
{{- fail "stringer requires a primitive-backed type" }}
{{- end }}
{{- if or .Values (not (isString .)) }}
{{- imports "strconv" }}
{{- end }}
{{ $prefix := arg "trimprefix" -}}
func (v {{ name . }}) String() string {
{{- if .Values }}
switch v {
{{- range .Values }}
case {{ .Name }}:
return {{ quote (trimPrefix .Name $prefix) }}
{{- end }}
default:
{{- if isString . }}
return "{{ name . }}(" + strconv.Quote(string(v)) + ")"
{{- else if isBool . }}
return "{{ name . }}(" + strconv.FormatBool(bool(v)) + ")"
{{- else if isUint . }}
return "{{ name . }}(" + strconv.FormatUint(uint64(v), 10) + ")"
{{- else if isInt . }}
return "{{ name . }}(" + strconv.FormatInt(int64(v), 10) + ")"
{{- else if isFloat . }}
return "{{ name . }}(" + strconv.FormatFloat(float64(v), 'g', -1, 64) + ")"
{{- else if isComplex . }}
return "{{ name . }}(" + strconv.FormatComplex(complex128(v), 'g', -1, 128) + ")"
{{- end }}
}
{{- else if isString . }}
return string(v)
{{- else if isBool . }}
return strconv.FormatBool(bool(v))
{{- else if isUint . }}
return strconv.FormatUint(uint64(v), 10)
{{- else if isInt . }}
return strconv.FormatInt(int64(v), 10)
{{- else if isFloat . }}
return strconv.FormatFloat(float64(v), 'g', -1, 64)
{{- else if isComplex . }}
return strconv.FormatComplex(complex128(v), 'g', -1, 128)
{{- end }}
}
{{ end }}
This handles go value types and go version of enum types. It’s easy to read and easy to predict what the resulting code will look like. It also has compile errors, so you can prevent misusage.
Go templates are perfect for this, just like scripting languages are perfect for small tasks that you want automated. It’s very straightforward and requires very little code to do most tasks.
Of course you can take it too far, just like how the
std.serde template works.
I must admit that I have not written that code, it’s all done by gpt 5.6. You might say that all
that is gibberish and metago doesn’t scale, to which I must say well, std.serde passes all tests
that I did throw at it and I could make it pretty fast!
Unmarshal
| Codec | 0.4 KB | 40 KB | 400 KB | 4 MB | Allocs @ 4 MB |
|---|---|---|---|---|---|
| serde | 755 | 828 | 875 | 967 | 20,004 |
| goccy/go-json | 512 | 542 | 553 | 528 | 150,017 |
| bytedance/sonic | 425 | 512 | 509 | 512 | 60,010 |
| jsoniter | 434 | 429 | 438 | 405 | 280,025 |
| easyjson | 379 | 365 | 360 | 342 | 200,020 |
| encoding/json | 123 | 129 | 125 | 122 | 260,030 |
Marshal
| Codec | 0.4 KB | 40 KB | 400 KB | 4 MB | Allocs @ 4 MB |
|---|---|---|---|---|---|
| serde | 1,147 | 1,029 | 1,109 | 1,160 | 1 |
| easyjson | 712 | 910 | 965 | 969 | 140 |
| goccy/go-json | 855 | 869 | 918 | 933 | 10,003 |
| encoding/json | 527 | 567 | 584 | 581 | 50,002 |
| bytedance/sonic | 345 | 370 | 390 | 382 | 10,027 |
| jsoniter | 304 | 324 | 326 | 322 | 80,018 |
You shouldn’t need to write something like serde in your day to day, but if you want, the template
is proof that it’s possible. AI can use the metago skill to write the exact functionality that you
want.
You can just copy the code that you think it could be derived from a type, paste it to your agent and ask it whether it could make a template from it.
Here’s an example of a typesafe active record like ORM done with Metago.
It’s still very experimental, and I don’t know if I’m ready to maintain also an ORM and 3 databases flavors. But maybe you, the reader (yes you!), might come up with another api.
Is this really that useful? or are you just hyping things up?
I no longer need go validator for required fields, I don’t need vektra/mockery anymore to generate mocks, I don’t need to maintain unmarshals and parse functions for my domain enums. With enough time I might also nuke sqlc and chi from my web projects.
And I’m sure there’s lot’s of room for creativity! CLI flag parsing, binding http handlers to a server, openapi spec generation (right now impossible, but we could support other generated assets, not just go!).
Does this make sense with AI generating all code?
I think so? I think that adding more determinism helps both humans and machines. You could say, a go text template is a very deterministic prompt. Ha look at me, I made an llm. And I give it to you for free, no subscriptions. Although money and sponsors are always welcome of course.
Long term sustainability
Metago v0.2.0 is available today as the first public version. The reference documentation lists the reserved keywords that I might use in the future. There’s also some features that would make templates a bit simpler to refactor, such as required arguments.
There’s no promise that I will maintain this forever, but I would argue that the core of this project is not that hard to understand. Unlike most software nowadays, I think that this can have a finish “DONE” line. It’s not like go is going to dramatically change any time soon, and metago is just templates in, go code out.
Install now:
go install github.com/guillemus/metago@latest