Attila Györffy
Software Engineer

Stop Naming Your Go Constructors New

Why Parse is better than New when your Go constructor is really parsing a string.

A validated value type takes a raw string and gives you back something typed and trustworthy. In Go, the default instinct is often to expose that as a pair like NewFoo and MustNewFoo. Which sounds fine right up until you think about it for more than six seconds.

NewFoo tells you only that a Foo comes back. Great. Spectacular. Very informative. MustNewFoo adds only the error-handling strategy, which is basically the API equivalent of saying, "same mystery, but louder." Neither name tells you what the function is actually doing with the input.

And that matters, because when a function accepts a raw string and returns a validated type, it is usually not creating something from thin air like a magician pulling a rabbit out of a hat. It is interpreting input, checking whether that input is valid, and only then reifying it as a typed value. In other words: it is parsing. Calling that New is technically survivable, but semantically a bit rubbish.

Take an AT Protocol record key: a string that must match a specific format. This is exactly the sort of API where NewFoo / MustNewFoo looks conventional at first, but hides the semantics that matter.

type RecordKey struct{ value string }

// "New" — but what is actually happening here?
// Is this for code reconstructing from storage?
// For HTTP input?
// For a test using a known-valid literal?
func NewRecordKey(v string) (RecordKey, error) { ... }

// "Must" explains error handling (panic vs return),
// but still says nothing about intent.
func MustNewRecordKey(v string) RecordKey { ... }

At first glance, those names seem harmless. And yes, your program will continue to compile, the sun will still rise, and nobody will kick down your door. But they flatten meaning that matters at the call site.

Consider two very different callers:

// Store: "I read this from SQLite; turn it back into the typed value."
rk, err := atproto.NewRecordKey(rkeyStr)

// Test: "I know this literal is valid; just give me the type."
rk := atproto.MustNewRecordKey("3jt5k2e4xab2s")

Both callers use the same conceptual entry point, but they are doing very different jobs.

The store layer is not creating a new record key in any meaningful domain sense. It is reconstructing one that already exists in persisted form. Nothing is being born. No miracle is occurring. A string came out of SQLite and you are trying to turn it back into a proper type without lying to yourself about what just happened.

The test is different again. It is not parsing untrusted input in the ordinary sense; it is asserting that a hardcoded literal is valid. That is closer to saying, "this value had better be valid or the programmer has done something daft."

And yes, we are calling it a trusted literal, which in software is about as much trust as you should ever place in anything. It just means you wrote it, so when it explodes, there is no mystery culprit to blame. The call is not saying, "this string is morally pure." It is saying, "if this is wrong, congratulations, you played yourself."

Those are distinct relationships to data, yet New makes them look like the same bland little ceremony. That is the real weakness of New here: not that it is technically wrong, but that it hides intent behind a name so generic it may as well be called DoThing.

What net/netip got right

Go's net/netip package avoids this ambiguity. It does not offer a NewAddr for string input. When an address comes from text, the API calls the operation what it is: parsing.

// "Parse" — this string comes from outside the type.
addr, err := netip.ParseAddr("192.168.1.1")

// "MustParse" — this should be valid; panic if it is not.
loopback := netip.MustParseAddr("127.0.0.1")

The names communicate intent immediately. No interpretive dance required.

Parse means: take a textual representation, validate it, and turn it into a typed value.

MustParse means: do the same thing, but treat failure as a programmer error. As in: this should have been valid, and if it is not, somebody in the codebase deserves a long, disappointed stare.

That distinction is useful because it maps directly to how code is actually written. A value coming from config, storage, environment variables, or HTTP input is something you parse. A literal embedded in a test or package-level constant is something you may reasonably MustParse.

This is not an isolated pattern either. It appears throughout the standard library:

url.Parse("https://example.com")                     // net/url
time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")     // time
template.Must(template.New("t").Parse("..."))        // text/template
regexp.MustCompile(`\d+`)                            // regexp

The standard library consistently distinguishes between composition and interpretation. When code is interpreting external representation, names like Parse and Compile are used. When code is assembling something from already-typed pieces, New is often the better fit.

Applying the pattern

Using that convention, the record key API becomes clearer:

// Validation lives in one place.
// Call sites: store layer, HTTP handlers, config parsing.
func ParseRecordKey(v string) (RecordKey, error) {
    if !tidRegexp.MatchString(v) {
        return RecordKey{}, fmt.Errorf("record key must be a valid TID: %q", v)
    }
    return RecordKey{value: v}, nil
}

// Panic wrapper calls Parse — no duplicated logic.
// Call sites: test fixtures, package-level constants.
func MustParseRecordKey(v string) RecordKey {
    rk, err := ParseRecordKey(v)
    if err != nil {
        panic(err)
    }
    return rk
}

Now the call sites read with much more precision:

// Store: reconstructing from persistence.
rk, err := atproto.ParseRecordKey(rkeyStr)

// Test: asserting a known-valid literal.
rk := atproto.MustParseRecordKey("3jt5k2e4xab2s")

The benefit is not just stylistic. The API now nudges the caller toward the right mental model. Instead of reaching for a generic New, they must implicitly answer a useful question: am I parsing input, or am I asserting a trusted literal?

That is the whole game. Good APIs make the correct path feel obvious. Bad names do the opposite: they smear together different situations and then act surprised when readers have to squint.

This is a small naming choice, but it makes code easier to scan, easier to reason about, and far less likely to read like it was assembled by committee in a beige conference room.

When New is still right

None of this means New is bad. New is perfectly fine. Lovely even. It just needs to stop turning up to jobs that belong to Parse. New is best reserved for cases where the operation is genuinely construction rather than interpretation.

Aggregate constructors that assemble already-typed values should usually keep New:

// Takes validated types — no string parsing happens here.
func NewActor(username Username, domain Domain, publicKey *rsa.PublicKey) (Actor, error)

// Assembles an aggregate root from typed parts.
func NewContent(id ContentID, kind ContentKind, body Body) (Content, error)

// Service constructor — wires dependencies.
func NewAdapter(client *Client, domain CanonicalDomain, store RecordKeyStore) *Adapter

Likewise, simple constructors where any input is acceptable can still use New:

func NewSummary(v string) Summary
func NewPublishedAt(t time.Time) PublishedAt

In both cases, the function is not trying to interpret a serialized representation. It is either composing typed parts or wrapping a value where no meaningful parsing step exists.

That leads to a practical rule:

  • If the input is a raw string that must be validated against some textual format, prefer Parse
  • If the inputs are already typed values being assembled into something larger, prefer New

The three callers

In practice, validated value types in most codebases tend to have three kinds of callers:

// 1. Store layer: reconstructing from database rows.
contentID, err := content.ParseContentID(row.ID)
body, err := content.ParseBody(row.Body)
title, err := content.ParseTitle(row.Title)

// 2. Server layer: parsing config or HTTP input.
domain, err := activitypub.ParseDomain(cfg.Domain)
username, err := activitypub.ParseUsername(cfg.Username)
pdsURL, err := atproto.ParsePDSURL(cfg.Bluesky.PDS)

// 3. Tests: hardcoded known-valid literals.
actor := activitypub.MustParseActorIRI("https://example.com/users/alice")
inbox := activitypub.MustParseInboxURL("https://remote.example/inbox")
relay := nostr.MustParseRelayURL("wss://relay.example.com")

The first two are parsing flows. The third is an assertion flow.

Once you see that pattern, it becomes very hard to unsee. Suddenly NewFoo starts looking like one of those labels in office kitchens that says food on the fridge. Yes, technically correct. Also useless. Most of these APIs do not need three or four constructor variants. They usually need just two:

  • ParseX for values coming from outside the type
  • MustParseX for literals that are expected to be valid

That pairing is enough for the overwhelming majority of cases, and it makes each call site say what it is doing.

The real point

This is ultimately an API design issue, not a naming nitpick. Names are not decorative. They are part of the contract you present to the caller.

If the function validates and interprets raw text, calling it New throws away useful information. If it assembles already-typed values, calling it Parse would be just as misleading. The point is not to be clever; the point is to be honest.

Good names should reflect the semantic operation, not just the return type.

So when a constructor is really parsing, call it Parse. When it is the panic-on-failure variant, call it MustParse. When it is actually constructing from typed parts, keep New.

Because if your API takes a dodgy little string from the outside world, interrogates it, validates it, and only then agrees to let it into polite society as a proper type, that function did not new anything. It parsed. Pretending otherwise is like watching airport security frisk a man for ten minutes and then calling the whole process NewPassenger.

And that is the whole point: your names should tell the truth about what your code is doing. Not vaguely. Not approximately. Not with the sort of hand-wavy optimism usually reserved for project estimates and CVs. Tell the truth.

  • Call parsing Parse
  • Call panic-wrapped parsing MustParse
  • Call actual construction New

Do that, and your code reads more clearly, your APIs carry their own intent, and the next poor bastard reading your package will not have to perform forensic analysis on a function called MustNewFoo just to work out that it was parsing a bloody string all along.

If you've read this far and you still think NewFoo is fine, I respect your commitment to being wrong. Either way, come tell me about it. I'm on Bluesky, Mastodon, Twitter X, and technically LinkedIn, though God knows why anyone would discuss Go naming conventions there. You can also find the code that started all of this on GitHub, where I promise not to name anything New unless it bloody well deserves it.

← attilagyorffy.com