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:
-
ParseXfor values coming from outside the type -
MustParseXfor 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.