public-rules › Go techs/go

Give text and its parsed form one owner

MEDIUM1.0.0
When to apply Before planning, writing, changing, or reviewing Go code that keeps a value's text and also needs its parsed or normalized form, such as a configuration setting, or code that parses such a field again or compares such values.

When a value is written as text, give its text and parsed form one owner that keeps them consistent. Don't store the text and its parsed form in two fields that every writer must keep in step. Prefer a value type whose only constructor parses the text; for a value used in one place, a text field with one parsing method is enough.

Implementation

Use a value type when the value crosses a package boundary or several structs carry the same kind of text:

  • Define a type with unexported fields holding the canonical parsed form and, when the original spelling must be kept, the text.
  • Make ParseX its only constructor from text. Implement UnmarshalText by calling it, and MarshalText by writing the text back, so JSON and YAML decoding validate it too.
  • Let the zero value mean "absent", with an IsZero method. The parser never returns the zero value, so absence is unambiguous. Use the omitzero JSON tag option (Go 1.24 and later) to leave an absent value out.
  • Give it String, returning the original spelling, and Equal, comparing canonical forms. Don't compare with == when the type keeps the spelling, because two spellings of one value would differ.

Outside the package, callers can construct only validated values or the valid zero value representing absence, so they never handle a parse error after construction.

Use a text field and one method when the value is local to one struct:

  • Store the text in one field, and add one method that parses it.
  • Return (T, error): the zero T when the text is empty, and an error when it's invalid. Use the zero value for absence only when it's unambiguous; when valid text can parse to the zero value, such as "0" to an integer, return presence separately, such as (T, bool, error). The field is exported, so struct literals and assignments can bypass any validation done at construction; the method must still report invalid text.
  • Validate the text where the struct is built as well, so users see errors early.

Either way:

  • Send every caller that needs the parsed form through the one constructor or method, and remove other places that parse the same text.
  • Compare canonical parsed values, not text, when meaning matters, such as deciding whether a setting changed.
  • Use the original spelling only where the author's text should appear: writing the file back, messages, and records of what the author wrote.

This rule covers retained text and its parsed or normalized form. It doesn't cover:

  • Only the parsed form stored: when nothing needs the original spelling, store the parsed form and format it for output.
  • Values that need I/O: a value derived through I/O, such as the commit a tag pointed to when it was fetched, is a snapshot of the outside world, not a function of the text. Store it as its own field and document what it records and when.
  • Other derived state: indexes, aggregates, and caches that aren't a parsed form of retained text.

Rationale

Two exported fields that describe one value create an invariant, such as "nil exactly when Ref is empty", that nothing enforces. Test fixtures, struct literals, and code that edits one field leave the other stale, and a reader can't tell which field is authoritative. Callers that distrust the stored copy parse the text again, often with slightly different rules, and callers that compare the text treat two spellings of one value as a change. A value type makes invalid values unrepresentable, so every holder of the value can trust it without checking. A single parsing method gives a local value the same single source of truth at less cost, as long as it reports invalid text.

Examples

Incorrect (counterexample):

type Source struct {
	Name string
	// Ref is the tag or commit the user wrote, or empty when the source follows the newest release.
	Ref string
	// ParsedRef is Ref after parsing; it is nil exactly when Ref is empty.
	ParsedRef *GitRef
}

// In another package:
func refChanged(source Source, recorded string) bool {
	return source.Ref != recorded
}

Every constructor and fixture must set both fields. Other packages parse Ref again because nothing guarantees ParsedRef is current. Comparing the text reports a change when the user rewrites release/5 as refs/tags/release/5, though both are the same reference.

Correct (value type):

// GitRef is a tag or commit reference. The zero value means no ref.
// ParseGitRef is the only way to build any other value, so every GitRef is valid.
type GitRef struct {
	canonical string // refs/tags/<name>, or a lowercase commit SHA
	text      string // the spelling the user wrote
}

func ParseGitRef(text string) (GitRef, error) { /* validate and normalize */ }

func (r GitRef) IsZero() bool                 { return r.canonical == "" }
func (r GitRef) String() string               { return r.text }
func (r GitRef) Equal(other GitRef) bool      { return r.canonical == other.canonical }
func (r GitRef) MarshalText() ([]byte, error) { return []byte(r.text), nil }

func (r *GitRef) UnmarshalText(text []byte) error {
	parsed, err := ParseGitRef(string(text))
	if err != nil {
		return err
	}
	*r = parsed
	return nil
}

type Source struct {
	Name string
	// Ref is zero when the source follows the newest release.
	Ref GitRef `json:"ref,omitzero"`
}

// In another package:
func refChanged(source Source, recorded GitRef) bool {
	return !source.Ref.Equal(recorded)
}

Ref has one representation that can't be invalid, so callers need no error handling and nothing parses it again. Equal compares canonical forms, so rewriting release/5 as refs/tags/release/5 isn't a change. It compares references, not the commits they resolve to: two different tags on one commit are still different choices. The configuration writer and messages use String, the user's spelling.

Correct (text and a method, for a value local to one struct):

type Job struct {
	Name string
	// Schedule is the schedule expression the user wrote, or empty when the job runs only on demand.
	Schedule string
}

// ParsedSchedule returns the job's schedule, the zero Schedule when it has none,
// and an error when Schedule isn't a valid expression.
func (j Job) ParsedSchedule() (Schedule, error) {
	if j.Schedule == "" {
		return Schedule{}, nil
	}
	return ParseSchedule(j.Schedule)
}

Only this struct uses the schedule, so a method is enough. It keeps a missing schedule and an invalid one apart.

Also correct (no change needed):

type Job struct {
	Name    string
	Timeout time.Duration
}

Nothing needs the text the user wrote, so the struct stores only the parsed time.Duration and formats it for output.

Validation

For each struct that keeps a value's text, look for a separate field holding its parsed or normalized form. Check that the value is either a value type built only by its parser, or text with one parsing method. Check that a parsing method reports invalid text as an error, not as absence. Search for other calls that parse the same text; each is a sign the representation isn't trusted. Check that comparisons that decide whether a value changed compare canonical forms, with Equal when the type keeps the spelling.

These aren't violations:

  • Two fields that are independent inputs, even when they're related.
  • A value type that holds both the text and the parsed form in unexported fields set only by its constructor.
  • A snapshot derived through I/O, stored as its own documented field.
  • A struct that mirrors an external format carrying both forms, as long as code reads the parsed form through one function.