DEV Community

Cover image for Go 1.26 Lets new() Take an Expression. Delete Your Pointer Helpers
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Go 1.26 Lets new() Take an Expression. Delete Your Pointer Helpers


In April 2021, Rob Pike opened golang/go#45624, "spec: expression to create pointer to simple types", as an alternative to an older proposal, #9097. Getting a pointer to a struct was always easy: &S{a: 3}. Getting a pointer to the number 3 was not. When Alan Donovan closed the issue in October 2025, he signed off with a joke: "All done, in only eleven years since #9097. ;-)"

That fix shipped in Go 1.26, released on February 10, 2026. The built-in new now accepts an expression, not only a type. new(42) compiles. So does new(time.Now()).

If your codebase has a ptr[T], a StringPtr, an IntPtr, or a utils/pointer.go file that every package imports, you can likely delete most of it now, and go fix will do the bulk of the work.

Why every Go codebase grew a pointer helper

Go has no Optional[T]. When a field can be absent, and absent has to mean something different from zero, the usual answer is a pointer. The Go 1.26 release notes call this out directly: the feature is "particularly useful when working with serialization packages such as encoding/json or protocol buffers that use a pointer to represent an optional value."

The trouble was always the write side. You cannot take the address of a constant or of a function call's result, so &42 and &time.Now() do not compile. Teams kept writing the same one-line escape hatch, and the ecosystem shipped its own copies: proto.String and proto.Int64 in protobuf-go, ptr.To in k8s.io/utils, ToPtr in samber/lo. The protobuf ones are a single line each: func String(v string) *string { return &v }.

Here is the pattern you probably have today. A settings payload where "retries not set" and "retries set to 0" are different instructions:

package main

import (
    "encoding/json"
    "fmt"
    "time"
)

func ptr[T any](v T) *T { return &v }

type Settings struct {
    Theme   *string    `json:"theme,omitempty"`
    Retries *int       `json:"retries,omitempty"`
    Beta    *bool      `json:"beta,omitempty"`
    Updated *time.Time `json:"updated,omitempty"`
}

func main() {
    s := Settings{
        Theme:   ptr("dark"),
        Retries: ptr(0),
        Beta:    ptr(false),
        Updated: ptr(time.Now()),
    }
    b, err := json.Marshal(s)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b))
}
Enter fullscreen mode Exit fullscreen mode

Retries: ptr(0) is the whole reason the pointer exists. omitempty drops a nil pointer, but a non-nil pointer to 0 is kept, so the JSON says "retries":0 instead of saying nothing.

A single burnt-orange arrow pointing from a small block labeled with a value straight into a pointer box, replacing a longer path through a helper machine
The helper was always a detour: take a value, park it in a variable, hand back the address.

The same code on Go 1.26

Delete ptr and call the built-in:

func main() {
    s := Settings{
        Theme:   new("dark"),
        Retries: new(0),
        Beta:    new(false),
        Updated: new(time.Now()),
    }
    b, err := json.Marshal(s)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(b))
}
Enter fullscreen mode Exit fullscreen mode

The behavior is the same as the helper. The spec's Allocation section now reads: "If the argument is an expression x, then new(x) allocates a variable of the type of x initialized to the value of x." new(int) still gives you a pointer to 0. new(123) gives you a pointer to 123. Both are *int.

For this to compile, the module (or the file, through a build constraint) has to declare Go 1.26 or later. If your go.mod still says go 1.25, bump it:

go mod edit -go=1.26
Enter fullscreen mode Exit fullscreen mode

That raises the minimum Go version for anyone who imports your module. For an application that is usually fine. For a library, it is a decision, and it is the first case where the old helper wins (more on that below). Go 1.27 has been out since August 19, 2026 and its release notes make no change to new(expr), so either current release works.

The sweep: go fix -newexpr

You do not have to hunt the call sites by hand. Go 1.26 also rewrote go fix into the home of Go's modernizers, built on the same analysis framework as go vet. One of them, newexpr, targets exactly this pattern. Alan Donovan's post on the Go blog shows the command to run only that fixer:

# preview first
go fix -diff -newexpr ./...

# apply
go fix -newexpr ./...

# read the analyzer's own docs
go tool fix help newexpr
Enter fullscreen mode Exit fullscreen mode

What it does, according to the analyzer docs and its source:

  • It finds "new-like" functions: one non-variadic parameter, one pointer result, and a body that is a single return &param. Reading the source, a generic ptr[T] should qualify too: the match is on the parameter itself rather than its type, and the call-site check carries a comment saying it is formulated that way "to support generics."
  • It rewrites the helper's body to return new(x) and adds a //go:fix inline directive, so the source-level inliner can treat calls to it as inlinable.
  • It rewrites call sites like ptr("dark") to new("dark").
  • It only touches files that already require Go 1.26, so bump go.mod first or the diff comes back empty.
  • The helper's own body is only rewritten when its file requires Go 1.26 as well. Whether calls to helpers from other modules, like proto.String, get rewritten depends on the analyzer recognising them as new-like, so check the -diff output instead of assuming either way.

The blog also advises running go fix "more than once until it reaches a fixed point; twice is usually enough." So run the apply step twice.

Then delete the helper and run go build ./.... Whatever the fixer left behind shows up as compile errors, and that list is your manual review queue. I haven't run this sweep myself, so treat the -diff output as the source of truth for your repo.

An isometric workbench where a small robotic arm sweeps a row of identical helper-function blocks into a bin, leaving three blocks standing that it skipped
go fix rewrites the safe calls and leaves the ones it cannot prove are equivalent.

The calls go fix leaves alone

The fixer refuses a rewrite when the new code would change the type. The check in the source compares the call's result type against a pointer to the argument's default type, and skips the call if they differ. That one rule explains most of what survives the sweep.

Untyped constants with a non-default type. The spec says an untyped constant passed to new is first converted to its default type. new(42) is *int. new(1.5) is *float64. So for a field typed *int64, new(42) does not compile, and ptr[int64](42) is not rewritten. Your hand-written version needs a conversion:

type Job struct {
    TimeoutMs *int64   `json:"timeout_ms,omitempty"`
    Ratio     *float32 `json:"ratio,omitempty"`
}

j := Job{
    TimeoutMs: new(int64(5000)),
    Ratio:     new(float32(0.25)),
}
Enter fullscreen mode Exit fullscreen mode

That works, and the type is explicit. But if a protobuf message has twenty int64 fields, proto.Int64(5000) reads better than new(int64(5000)), and keeping it is a defensible call.

Shadowed new. If a package declares its own new (a constructor, a local variable), the fixer skips calls in that scope. Rename the local first.

Untyped composite literals. The analyzer's source skips arguments like {1, 2} because new({1, 2}) is a compile error. For structs you already had the better tool: &Config{Port: 8080}.

Where the helper is still the clearer choice

The fixer handles the mechanical part. These are judgment calls, and the old helper wins in each one.

You need a function value. The spec's built-in functions section says built-ins "can only appear in call expressions; they cannot be used as function values." So new cannot be passed where a func(T) *T is expected:

func mapSlice[T, U any](s []T, f func(T) U) []U {
    out := make([]U, 0, len(s))
    for _, v := range s {
        out = append(out, f(v))
    }
    return out
}

names := []string{"a", "b"}
ptrs := mapSlice(names, ptr[string]) // compiles
// mapSlice(names, new)              // does not compile
Enter fullscreen mode Exit fullscreen mode

A closure works too (func(s string) *string { return new(s) }), but at that point the named helper is shorter.

Your library supports Go versions before 1.26. new(expr) is a language change, gated by the go directive. If your go.mod promises 1.24 compatibility, you keep the helper until you drop that promise.

The read side. new(expr) only helps you build pointers. Reading an optional field with a default still needs a helper, like ptr.Deref in k8s.io/utils (func Deref[T any](ptr *T, def T) T). Delete ptr[T], keep deref[T].

new(x) is not &x. It allocates a fresh variable holding a copy of x, exactly like the helper did:

n := 1
p := new(n) // copy of n
q := &n     // address of n
n = 2
// *p is 1, *q is 2
Enter fullscreen mode Exit fullscreen mode

If you wanted aliasing, you still want &.

A five-minute checklist

  1. Bump go.mod to go 1.26 or later (applications only, or libraries ready to drop older versions).
  2. Run go fix -diff -newexpr ./... and read the diff.
  3. Apply with go fix -newexpr ./..., twice.
  4. Delete the helper, run go build ./..., and fix what is left by hand: add conversions like new(int64(5000)), or keep a typed helper on purpose.
  5. Keep any helper you pass as a function value, and keep your deref helper.

Run step 2 on your repo first. The diff tells you how big the job really is.


If this was useful

If the tricky part of this post was the language underneath it, The Complete Guide to Go Programming covers that ground: pointers and zero values, why built-ins like new behave differently from ordinary functions, how untyped constants pick their default type, and the standard library packages such as encoding/json that lean on pointers for "absent". It is the reference to keep open when a release like 1.26 changes a rule you thought was settled.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)