What better way to start the new year than by hacking the go compiler.
See..
I'm building a tiny and first and foremost pretty SQL query builder.
And I want this to work:
Select(
"users",
Alias("users", "u"),
).From(
"users",
).Where(
Eq(Col("u", "id"), Arg(1)),
).Build()
The thing is: how do I accept both string and stuff that can render to strings in one API without forcing myself to wrap every literal or make funny business like this:
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case fmt.Stringer:
return x.String()
default:
panic(fmt.Sprintf("unsupported SQL text type %T", v))
}
}
Pretty ugly, right?
Fortunately, Go (in theory) has already a perfect solution for this:
// In the standard library we find:
//
// type fmt.Stringer interface { String() string }
…except string doesn't implement it.
Soooo.. let's do it ourselves, shall we?
To do this, we will learn what it takes to add a synthetic builtin method to a predeclared type in Go, and why the change have to touch:
- the compiler’s type universe
- selector lookup (in multiple type-checkers)
- method-call rewriting
- export data reading
go/types(for tooling)- runtime call targets (so reflect + interfaces can actually call it)
The idea: treat string as a renderable node
If string implements String() string, then a query builder can simply accept fmt.Stringer and let us cook:
import "fmt"
type alias struct {
table string
as string
}
// Alias returns a value that knows how to render itself.
// The underlying struct is intentionally unexported.
func Alias(table, as string) fmt.Stringer {
return alias{table: table, as: as}
}
func (a alias) String() string {
return a.table + " AS " + a.as
}
// Now `string` and `Alias(...)` can both be passed as fmt.Stringer.
func Select(items ...fmt.Stringer) Query {
// ...
return Query{}
}
This makes our query builder API pretty.. see:
Select("tablename", Alias("tablename2", "alias"))
Big 🧠 time: What needs to change?
At a high level, the patch will do the following:
- Compiler universe: inject a method onto the predeclared type
string. - Selector lookup: teach the type-checkers to resolve
.Stringonstring(including aliases) - Untyped constants: make
"x".String()work by defaulting the receiver from untyped string →stringbefore lookup - IR rewriting: make method-call rewriting handle the defaulted receiver cleanly
- Export data: make method expressions involving
stringsurvive export/import paths - Tooling parity: mirror the behavior in
go/typesandcmd/compile/internal/types2, and adjust stdlib tests that assumepkg==nilmeans predeclared - Runtime call targets: provide linker-visible symbols so direct calls, interface calls, and reflection have somewhere to jump
The compiler universe: string has a method now
In the compiler, predeclared types are created early in InitTypes. The simplest way to make string gain a method is to append a method field to its method list.
That's what our little block will do in src/cmd/compile/internal/types/universe.go:
// Builtin method: string.String() string
//
// This makes the predeclared type string satisfy interfaces such as fmt.Stringer,
// and enables direct selectors like `"x".String()`.
//
// The actual call targets are provided by runtime stubs (see runtime package).
{
recv := NewField(src.NoXPos, nil, Types[TSTRING])
res := NewField(src.NoXPos, nil, Types[TSTRING])
sig := NewSignature(recv, nil, []*Field{res})
m := NewField(src.NoXPos, LocalPkg.Lookup("String"), sig)
Types[TSTRING].SetMethods(append(Types[TSTRING].Methods(), m))
}
Note: This is not a user declared method. It doesn't exist in source. It's a compiler-level synthetic method whose body is provided elsewhere.
What a “Universe object” is (in go/types)
In go/types, the Universe is the global predeclared scope that exists "before any package is type-checked". It contains the identifiers the Go spec says are always available without importing anything. A Universe object is simply an Object that lives in that Universe scope, i.e. something you can get via:
obj := types.Universe.Lookup("string") // predeclared type name
What's in the Universe?
- Predeclared types:
bool,string,int,error, … - Predeclared constants:
true,false,iota,nil - Predeclared functions:
len,make,append,panic, …
Why the patch mirrors the synthetic method in go/types
- inject into
lookupFieldOrMethodImpl(for selector resolution) - inject into
NewMethodSet(so interface satisfaction works)
The method set part is important because interface satisfaction is defined in terms of method sets.
So go/types needs to treat string the same way the compiler does:
And it updates the stdlib tests to treat (string).String like the other predeclared “specials”:
stringString, _, _ := LookupFieldOrMethod(Typ[String], false, nil, "String") // (string).String
predeclared := obj == Universe.Lookup(obj.Name()) || obj == errorError || obj == stringString
This is also where you run into a classic footgun: a synthetic object can end up looking “user-defined” to code paths that assume every non-Universe object must have a package.
What does having a package means, i.e. is pkg==nil?
Most Universe objects are not declared in any package, so in go/types they typically have obj.Pkg() == nil.
Some tests use that as a heuristic: “if it's predeclared, it should look like a Universe object”.
That's why these stdlib tests need an explicit exception for it—similar to another special-case: (error).Error.
Runtime call targets: go:builtin.string.String and go:builtin.(*string).String
If the compiler is going to emit calls to a method, it needs symbols to call.
For "real" methods, those symbols come from compiling method bodies. For a synthetic builtin method, you need a trampoline.
Note: A trampoline is a function that is used to call a function that is not in the same package.
That's why there's a tiny runtime file that linknames two functions:
//go:linkname builtinStringString go:builtin.string.String
func builtinStringString(s string) string { return s }
//go:linkname builtinPtrStringString go:builtin.(*string).String
func builtinPtrStringString(ps *string) string { return *ps }
The distinction matters because the runtime has two “places” it can store a method entry:
- direct calls / reflect
Tfnwant a concrete function symbol for(string).String - interface dispatch / itab
Ifnwants the interface-call entry for the dynamic type
Conceptually:
Selector lookup across multiple type-checkers
Go has "multiple ways" to perform selector lookup depending on context:
cmd/compile/internal/types2(compiler's newer type checker)go/types(tooling checker used by gopls, analyzers, etc.)
Both need to agree that:
stringhas aString()method- any alias to
stringshould see it too - lookup should treat it like an exported method (so it works cross-package)
That's why both lookup.go files add the same concept:
- a cached synthetic
*Funcdescribingstring.String() string - an injection point in
lookupFieldOrMethodImplfor Basic kindString
Untyped constants: why "x".String() needs special handling
This is unfortunately not trivial.
The string literal "x" starts its life as an untyped string constant.
Method lookup however happen on the operand's type.
So if you inject a method only for the typed predeclared string, then the lookup won't see it unless you first default the literal, i.e. convert it to a string type:
string("x").String()
which is not pretty, so not what we want.
So both go/types and types2 have to default the receiver to string for an untyped string constant before calling into lookup:
if b, _ := x.typ.(*Basic); b != nil && b.kind == UntypedString {
x.typ = Typ[String]
}
In the compiler frontend, you also need to handle the method-call rewriting phase.
So the compiler turns this:
"x".String()
into this (conceptually):
string.String("x")
…but it can't do that rewrite cleanly if the receiver is still untyped.
The compiler internally uses FixMethodCall to force the receiver to a concrete type before it constructs the method expression:
if recv != nil && recv.Type() == types.UntypedString {
recv = DefaultLit(recv, types.Types[types.TSTRING])
dot.X = recv
}
Here's the "before vs after" flow for a method call on an untyped string literal:
Export data + method expressions: noder/reader.go
One more place untyped-vs-typed matters is export data and the nodes built from it.
In src/cmd/compile/internal/noder/reader.go, methodExpr() can see a receiver type that is still types.UntypedString.
If you try to form a method expression against that, it won't match the stenciled wrappers/signatures the reader expects.
So the reader normalizes it:
if recv == types.UntypedString {
recv = types.Types[types.TSTRING]
}
This is the kind of plumbing that's easy to miss until you hit "weird failures in unrelated packages".
Tooling parity: go/types must agree with the compiler
Even if the compiler is happy, your tooling can be wrong:
- gopls can claim
"x".String()is invalid while the compiler accepts it - analyzers can get method sets wrong
go/typesstdlib checks can fail because they assume “pkg==nil implies Universe object”
End-to-end behavior: interface satisfaction + reflect
With the compiler universe and method sets updated, you now get:
var _ fmt.Stringer = "x"(interface satisfaction)"x".String() == "x"(direct selector)reflect.TypeOf("").MethodByName("String")works (reflection)
This gives us a good base to build pretty query builders around, we can pass now string and Alias to the query builder!
Bringing it back to the SQL query builder
Once string implements String() string, we can design the SQL query builder API around a single type: fmt.Stringer.
This is the API we are aiming for:
Select("users", Alias("users", "u")).
From("users").
Where(Eq(Col("u", "id"), Arg(1))).
Build()
Now the rule becomes:
- pass
stringfor "raw table name" - pass struct (like
Alias) for "table name with alias"
We get typesafety and lit syntax 🔥 ..
Closing thoughts
So.. shipping something like this is not cool with the core devs usually.
Hence, to make them less scared from our code, we need to give them some numbers to look at.
Put differently: we need to show them that the changes are not harmful to the performance of the language.
Luckily go has a way to run benchmarks on the go source code itself, which we can do like this:
git clone https://github.com/4thel00z/go.git
cd go
export GOROOT=$PWD
export GO="$GOROOT/bin/go"
# Show current state (for sanity)
/usr/bin/git status --porcelain
# Baseline bench (clean tree)
rm -rf /tmp/gocache-baseline /tmp/gomodcache-baseline
mkdir -p /tmp/gocache-baseline /tmp/gomodcache-baseline
GOCACHE=/tmp/gocache-baseline GOMODCACHE=/tmp/gomodcache-baseline \
"$GO" test runtime -run='^$' -bench='BenchmarkCompareString|BenchmarkConcatString|BenchmarkSliceByteToString' -benchmem -benchtime=200ms -count=10 | tee /tmp/bench-baseline.txt
# Restore changes
/usr/bin/git checkout feat/add-string-String-method
# After bench (with the changes)
rm -rf /tmp/gocache-after /tmp/gomodcache-after
mkdir -p /tmp/gocache-after /tmp/gomodcache-after
GOCACHE=/tmp/gocache-after GOMODCACHE=/tmp/gomodcache-after \
"$GO" test runtime -run='^$' -bench='BenchmarkCompareString|BenchmarkConcatString|BenchmarkSliceByteToString' -benchmem -benchtime=200ms -count=10 | tee /tmp/bench-after.txt
# Summarize using median ns/op per benchmark with a little python script
python3 - <<'PY'
import re, statistics
rx = re.compile(r'^(Benchmark\S+)\s+\d+\s+([0-9.]+)\s+ns/op(?:\s+([0-9.]+)\s+B/op\s+([0-9.]+)\s+allocs/op)?')
def parse(path):
d = {}
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
m = rx.match(line.strip())
if not m:
continue
name = m.group(1)
ns = float(m.group(2))
b = float(m.group(3) or 0.0)
a = float(m.group(4) or 0.0)
d.setdefault(name, {'ns': [], 'b': [], 'a': []})
d[name]['ns'].append(ns)
d[name]['b'].append(b)
d[name]['a'].append(a)
# median per metric
out = {}
for k, v in d.items():
out[k] = (statistics.median(v['ns']), statistics.median(v['b']), statistics.median(v['a']), len(v['ns']))
return out
base = parse('/tmp/bench-baseline.txt')
aft = parse('/tmp/bench-after.txt')
names = sorted(set(base) & set(aft))
print(f"Benchmarks compared: {len(names)}")
print("name\tbaseline_median_ns\tafter_median_ns\tratio\tbaseline_B\tafter_B\tbaseline_allocs\tafter_allocs\tn")
for n in names:
b_ns, b_b, b_a, bn = base[n]
a_ns, a_b, a_a, an = aft[n]
ratio = a_ns / b_ns if b_ns else float('inf')
print(f"{n}\t{b_ns:.4f}\t{a_ns:.4f}\t{ratio:.4f}\t{b_b:g}\t{a_b:g}\t{b_a:g}\t{a_a:g}\t{min(bn,an)}")
PY
With the following results:
BenchmarkCompareStringBig-12 19556.5 -> 19428.5 0.9935x
BenchmarkCompareStringBigUnaligned-12 20322.5 -> 20203.0 0.9941x
BenchmarkCompareStringDifferentLength-12 0.2975 -> 0.3065 1.0302x
BenchmarkCompareStringEqual-12 1.6480 -> 1.7175 1.0422x
BenchmarkCompareStringIdentical-12 0.2896 -> 0.2965 1.0238x
BenchmarkCompareStringSameLength-12 1.7970 -> 1.5655 0.8712x
BenchmarkConcatStringAndBytes-12 10.9400 -> 11.0900 1.0137x
BenchmarkSliceByteToString/1-12 1.2700 -> 1.4075 1.1083x
BenchmarkSliceByteToString/2-12 7.3090 -> 7.7950 1.0665x
BenchmarkSliceByteToString/4-12 7.3750 -> 7.7620 1.0525x
BenchmarkSliceByteToString/8-12 8.0220 -> 8.1630 1.0176x
BenchmarkSliceByteToString/16-12 9.1360 -> 9.2320 1.0105x
BenchmarkSliceByteToString/32-12 9.9810 -> 10.0295 1.0049x
BenchmarkSliceByteToString/64-12 12.9550 -> 13.0750 1.0093x
BenchmarkSliceByteToString/128-12 18.2350 -> 18.1700 0.9964x
Concerning the allocations, we found no changes in B/op or allocs/op for any benchmark (all identical in the medians).
Now.. If you got a bit curious and want to play with the code, you can find it here.
How can I use the go fork with the changes?
git clone https://github.com/4thel00z/go.git
cd go
git checkout feat/add-string-String-method
./make.bash
export GOROOT=$PWD
export PATH="$GOROOT/bin:$PATH"
go version
# go version go1.26-devel_b28808d838 Tue Dec 30 14:29:01 2025 -0800 darwin/arm64
go env GOROOT GOVERSION GOTOOLDIR
# will print for example:
# <redacted>/go/src/go
# go1.26-devel_b28808d838 Tue Dec 30 14:29:01 2025 -0800
# <redacted>/go/src/go/pkg/tool/darwin_arm64
Cheers!
Newsletter
Keep reading.
One email when something new lands. No spam.